Refine Constant cockpit and Rust runtime
This commit is contained in:
@@ -19,3 +19,4 @@ output/
|
|||||||
codex-profiles/
|
codex-profiles/
|
||||||
profiles/
|
profiles/
|
||||||
state/
|
state/
|
||||||
|
target/
|
||||||
|
|||||||
Generated
+727
@@ -0,0 +1,727 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cassowary"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "castaway"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compact_str"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
|
||||||
|
dependencies = [
|
||||||
|
"castaway",
|
||||||
|
"cfg-if",
|
||||||
|
"itoa",
|
||||||
|
"rustversion",
|
||||||
|
"ryu",
|
||||||
|
"static_assertions",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "constant"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"crossterm",
|
||||||
|
"ratatui",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||||
|
dependencies = [
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indoc"
|
||||||
|
version = "2.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "instability"
|
||||||
|
version = "0.3.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"indoc",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.184"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
|
[[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.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[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.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cassowary",
|
||||||
|
"compact_str",
|
||||||
|
"crossterm",
|
||||||
|
"indoc",
|
||||||
|
"instability",
|
||||||
|
"itertools",
|
||||||
|
"lru",
|
||||||
|
"paste",
|
||||||
|
"strum",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-truncate",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_syscall"
|
||||||
|
version = "0.5.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[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_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "smallvec"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-truncate"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||||
|
dependencies = [
|
||||||
|
"itertools",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width 0.1.14",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[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.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_gnullvm",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "constant"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Rust front-controller for the Constant AI cockpit"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
crossterm = "0.28"
|
||||||
|
ratatui = "0.29"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "constant"
|
||||||
|
path = "src/main.rs"
|
||||||
@@ -36,6 +36,7 @@ The vibe is somewhere between:
|
|||||||
|
|
||||||
Current build status in this repo:
|
Current build status in this repo:
|
||||||
|
|
||||||
|
- a Rust front-controller for the canonical `constant` entrypoint
|
||||||
- a 4-pane host-local `tmux` session per machine
|
- a 4-pane host-local `tmux` session per machine
|
||||||
- a fleet cockpit with one window per machine plus a central `Constant` window
|
- a fleet cockpit with one window per machine plus a central `Constant` window
|
||||||
- `Constant`, the orchestration CLI on top of the cockpit
|
- `Constant`, the orchestration CLI on top of the cockpit
|
||||||
@@ -51,6 +52,24 @@ Current build status in this repo:
|
|||||||
The canonical script surface is `constant-*`.
|
The canonical script surface is `constant-*`.
|
||||||
Legacy `zellij-ai-*` aliases remain only as compatibility shims.
|
Legacy `zellij-ai-*` aliases remain only as compatibility shims.
|
||||||
|
|
||||||
|
Migration note:
|
||||||
|
|
||||||
|
- the entrypoint is now moving to Rust
|
||||||
|
- `doctor`, `agents`, `skills`, `tui`, `cockpit`, `mission create`, `mission plan`, `mission run`, `mission status`, `mission tail`, `mission verify`, `mission retry`, `mission summarize`, and `delegate` now run from the Rust binary
|
||||||
|
- Rust now prefers `fleet.toml`, `models.toml`, and `memory.toml`, while keeping JSON mirrors for compatibility during migration
|
||||||
|
- `memory`, `buddy`, and `fleet` still hand off to the existing Python core during migration
|
||||||
|
- the public `./scripts/Constant` wrapper now tries the Rust binary first, but runs a fast startup probe before handing control over
|
||||||
|
- if macOS stalls in code-signing / policy evaluation for freshly built binaries, the wrapper falls back automatically to the stable Python path
|
||||||
|
- when `constant` is launched from inside the Codex runner itself, the wrapper skips the Rust path entirely and goes straight to Python to avoid the known macOS startup stall in that environment
|
||||||
|
- use `CONSTANT_USE_RUST=1 ./scripts/Constant ...` to force the raw Rust path, or `CONSTANT_USE_PYTHON=1 ./scripts/Constant ...` to force Python
|
||||||
|
- after changing macOS security settings, use `CONSTANT_RUST_RECHECK=1 ./scripts/Constant ...` to force a fresh Rust startup probe instead of waiting for the fallback cache TTL
|
||||||
|
|
||||||
|
macOS note:
|
||||||
|
|
||||||
|
- if Rust keeps falling back from a normal terminal app, check `System Settings -> Privacy & Security -> Developer Tools`
|
||||||
|
- allow your terminal app there so it can run locally built binaries without extra security-policy stalls
|
||||||
|
- then rerun with `CONSTANT_RUST_RECHECK=1 ./scripts/Constant --help`
|
||||||
|
|
||||||
## Why This Exists
|
## Why This Exists
|
||||||
|
|
||||||
Most AI tooling is designed like a vending machine:
|
Most AI tooling is designed like a vending machine:
|
||||||
@@ -117,6 +136,49 @@ That gives you:
|
|||||||
- cockpit handoff
|
- cockpit handoff
|
||||||
- SSH discovery and fleet deployment bootstrap
|
- SSH discovery and fleet deployment bootstrap
|
||||||
|
|
||||||
|
### 3.5. Workflow skills
|
||||||
|
|
||||||
|
`Constant` now has a first-class workflow skill layer.
|
||||||
|
|
||||||
|
Public workflow skills:
|
||||||
|
|
||||||
|
- `spec-planner`
|
||||||
|
- `architecture-brainstorm`
|
||||||
|
- `repo-onboarding`
|
||||||
|
- `task-decomposer`
|
||||||
|
- `pr-review-prep`
|
||||||
|
|
||||||
|
Recommended minimal stack:
|
||||||
|
|
||||||
|
- `spec-planner`
|
||||||
|
- `architecture-brainstorm`
|
||||||
|
- `task-decomposer`
|
||||||
|
|
||||||
|
Recommended workflow:
|
||||||
|
|
||||||
|
1. `spec-planner`
|
||||||
|
2. `architecture-brainstorm`
|
||||||
|
3. `task-decomposer`
|
||||||
|
4. implementation on the selected agent / machine / CLI
|
||||||
|
5. `pr-review-prep`
|
||||||
|
|
||||||
|
Inspect them with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/Constant skills --json
|
||||||
|
./scripts/Constant skills --public-only --json
|
||||||
|
./scripts/Constant agents --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Create and route missions through these skills today with the Rust CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/Constant mission create "Je veux ajouter un systeme de notifications" --workspace "$PWD"
|
||||||
|
./scripts/Constant delegate <mission_id> --skill architecture-brainstorm --json
|
||||||
|
```
|
||||||
|
|
||||||
|
The richer conversation-first chat surface is still part of the Python-side migration path.
|
||||||
|
|
||||||
### 4. Durable memory
|
### 4. Durable memory
|
||||||
|
|
||||||
The memory layer is being built to support:
|
The memory layer is being built to support:
|
||||||
@@ -204,7 +266,30 @@ If `Constant` is on your `PATH`, you can also just run:
|
|||||||
Constant
|
Constant
|
||||||
```
|
```
|
||||||
|
|
||||||
In interactive mode, `Constant` with no arguments now opens the TUI by default.
|
In interactive mode, `Constant` with no arguments now opens or attaches the full fleet cockpit by default.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- a top tmux tab bar for `Constant`, the local machine, and each remote machine
|
||||||
|
- the active tab is forced back to `Constant` when you launch `constant`
|
||||||
|
- the `Constant` TUI inside the central `Constant` window
|
||||||
|
- one machine window per host in the fleet
|
||||||
|
|
||||||
|
For now, the central `Constant` window is intentionally forced onto the richer Python chat-first TUI so the UX stays closer to a Claude Code-style conversation surface while the Rust TUI is still catching up.
|
||||||
|
|
||||||
|
The current interaction model is:
|
||||||
|
|
||||||
|
- chat-first by default
|
||||||
|
- prompt focused on launch
|
||||||
|
- `/` opens the workflow skill palette
|
||||||
|
- `Esc` drops into cockpit command mode
|
||||||
|
- `f` toggles between `chat-focus` and `cockpit-detail`
|
||||||
|
|
||||||
|
If you want only the standalone TUI without attaching the fleet session, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/Constant tui --workspace "$PWD"
|
||||||
|
```
|
||||||
|
|
||||||
### Discover and deploy a fleet
|
### Discover and deploy a fleet
|
||||||
|
|
||||||
@@ -216,7 +301,7 @@ In interactive mode, `Constant` with no arguments now opens the TUI by default.
|
|||||||
- validates candidates with a short SSH probe
|
- validates candidates with a short SSH probe
|
||||||
- lets you select targets interactively
|
- lets you select targets interactively
|
||||||
- asks for SSH user and machine labels
|
- asks for SSH user and machine labels
|
||||||
- writes `~/.config/constant/fleet.json`
|
- writes `~/.config/constant/fleet.toml` and a compatibility `fleet.json` mirror
|
||||||
- can immediately deploy the runtime to the selected machines
|
- can immediately deploy the runtime to the selected machines
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -237,7 +322,7 @@ You can pass raw SSH seeds such as:
|
|||||||
- `root@192.168.0.119`
|
- `root@192.168.0.119`
|
||||||
- `lab-a`
|
- `lab-a`
|
||||||
|
|
||||||
If a `fleet.json` contains `repo_dir`, the shell launchers and fleet installer will reuse it automatically.
|
If `fleet.toml` or `fleet.json` contains `repo_dir`, the shell launchers and fleet installer will reuse it automatically.
|
||||||
|
|
||||||
For a fully non-interactive run, pass explicit hosts and `--yes`:
|
For a fully non-interactive run, pass explicit hosts and `--yes`:
|
||||||
|
|
||||||
@@ -253,24 +338,25 @@ Constant fleet configure \
|
|||||||
|
|
||||||
### TUI keys
|
### TUI keys
|
||||||
|
|
||||||
`Constant tui` gives you:
|
`Constant tui` now gives you:
|
||||||
|
|
||||||
- a mission deck
|
- a chat-first central conversation surface
|
||||||
- a mission board
|
- a thread sidebar
|
||||||
- a runtime view of machine windows and pane states
|
- a chat-focus mode that behaves closer to Claude Code
|
||||||
|
- a detailed cockpit view you can toggle back in when needed
|
||||||
|
- a capture popup for pane output
|
||||||
- a `hexapus` buddy rail
|
- a `hexapus` buddy rail
|
||||||
- a bottom timeline mixed with memory echoes
|
- a bottom status / key strip
|
||||||
|
|
||||||
Useful keys:
|
Useful keys:
|
||||||
|
|
||||||
- `j` / `k`: move between missions
|
- `j` / `k`: move between missions
|
||||||
|
- `f`: toggle `chat-focus` / `cockpit-detail`
|
||||||
- `[` / `]`: move between machines in the runtime view
|
- `[` / `]`: move between machines in the runtime view
|
||||||
- `1`..`4`: focus `claude`, `codex`, `copilot`, `vibe` on the selected machine
|
- `1`..`4`: select `claude`, `codex`, `copilot`, `vibe` on the selected machine
|
||||||
- `o`: jump to the selected machine window
|
- `o`: focus the selected machine + pane in the real cockpit
|
||||||
- `r`: restart the selected pane
|
- `r`: restart the selected pane
|
||||||
- `x`: capture a short preview of the selected pane
|
- `x`: open a capture view of the selected pane
|
||||||
- `e`: rebuild memory for the current workspace
|
|
||||||
- `s`: summarize the selected mission into durable memory
|
|
||||||
- `z`: open the full fleet cockpit
|
- `z`: open the full fleet cockpit
|
||||||
- `q`: quit the TUI
|
- `q`: quit the TUI
|
||||||
|
|
||||||
@@ -301,11 +387,11 @@ Public example config:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.config/constant
|
mkdir -p ~/.config/constant
|
||||||
cp examples/fleet.example.json ~/.config/constant/fleet.json
|
cp examples/fleet.example.toml ~/.config/constant/fleet.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
Legacy `~/.config/constant/fleet.yaml` is still read for compatibility, but the public-facing format is now JSON.
|
Legacy `~/.config/constant/fleet.json` and `fleet.yaml` are still read for compatibility, but the native format is now TOML.
|
||||||
The shell launchers also read `fleet.json` now, so fleet tabs, install/check, and bridge helpers stay aligned with the same config.
|
The shell launchers read both `fleet.toml` and `fleet.json`, so fleet tabs, install/check, and bridge helpers stay aligned during the migration.
|
||||||
|
|
||||||
## Messaging
|
## Messaging
|
||||||
|
|
||||||
@@ -383,7 +469,9 @@ Planned layers for the public `Constant` repo:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
constant/ Python orchestration core
|
constant/ Python orchestration core
|
||||||
|
src/ Rust CLI, missions, cockpit, and native TUI
|
||||||
examples/fleet.example.json public fleet template
|
examples/fleet.example.json public fleet template
|
||||||
|
examples/fleet.example.toml public fleet template in the native config format
|
||||||
scripts/Constant canonical CLI entrypoint
|
scripts/Constant canonical CLI entrypoint
|
||||||
scripts/constant-deploy.sh discovery + selection + fleet deployment CLI
|
scripts/constant-deploy.sh discovery + selection + fleet deployment CLI
|
||||||
scripts/constant-machine.sh canonical single-machine cockpit entrypoint
|
scripts/constant-machine.sh canonical single-machine cockpit entrypoint
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_AGENTS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": "planner",
|
||||||
|
"label": "Planner",
|
||||||
|
"role": "planner",
|
||||||
|
"primary_cli": "claude",
|
||||||
|
"capabilities": ["spec", "planning", "review", "summarization", "delegation"],
|
||||||
|
"preferred_layers": ["reflection", "execution-prep"],
|
||||||
|
"manual_only": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "executor",
|
||||||
|
"label": "Executor",
|
||||||
|
"role": "executor",
|
||||||
|
"primary_cli": "codex",
|
||||||
|
"capabilities": ["implementation", "debugging", "refactor", "tooling", "ops"],
|
||||||
|
"preferred_layers": ["execution"],
|
||||||
|
"manual_only": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "analyst",
|
||||||
|
"label": "Analyst",
|
||||||
|
"role": "analyst",
|
||||||
|
"primary_cli": "vibe",
|
||||||
|
"capabilities": ["brainstorm", "alternatives", "research", "comparison"],
|
||||||
|
"preferred_layers": ["reflection"],
|
||||||
|
"manual_only": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "assistant",
|
||||||
|
"label": "Assistant",
|
||||||
|
"role": "assistant",
|
||||||
|
"primary_cli": "copilot",
|
||||||
|
"capabilities": ["interactive-help", "inline-suggestions"],
|
||||||
|
"preferred_layers": ["manual"],
|
||||||
|
"manual_only": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SKILLS: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": "spec-planner",
|
||||||
|
"label": "Spec Planner",
|
||||||
|
"layer": "reflection",
|
||||||
|
"visibility": "public",
|
||||||
|
"summary": "Transformer une demande floue en spec exploitable avant de coder.",
|
||||||
|
"when_to_use": [
|
||||||
|
"quand on part d'une idee brute",
|
||||||
|
"quand il faut cadrer avant implementation",
|
||||||
|
"quand on veut eviter de coder trop tot",
|
||||||
|
],
|
||||||
|
"input_examples": [
|
||||||
|
"Je veux ajouter un systeme de notifications",
|
||||||
|
"On doit refondre l'auth OAuth",
|
||||||
|
],
|
||||||
|
"output_expected": [
|
||||||
|
"objectif",
|
||||||
|
"perimetre",
|
||||||
|
"hors_perimetre",
|
||||||
|
"contraintes",
|
||||||
|
"hypotheses",
|
||||||
|
"criteres_acceptation",
|
||||||
|
"risques",
|
||||||
|
"questions_ouvertes",
|
||||||
|
"plan_minimal",
|
||||||
|
],
|
||||||
|
"usage_prompt": (
|
||||||
|
"Fais une spec executable a partir de cette demande. "
|
||||||
|
"Identifie les zones floues, propose des hypotheses raisonnables, "
|
||||||
|
"liste les criteres d'acceptation et termine par un plan d'implementation minimal."
|
||||||
|
),
|
||||||
|
"keywords": [
|
||||||
|
"spec planner",
|
||||||
|
"spec-planner",
|
||||||
|
"spec",
|
||||||
|
"cadrer",
|
||||||
|
"clarifier",
|
||||||
|
"clarify",
|
||||||
|
"requirements",
|
||||||
|
"scope",
|
||||||
|
"perimetre",
|
||||||
|
"oauth",
|
||||||
|
"notifications",
|
||||||
|
"idea brute",
|
||||||
|
"je veux",
|
||||||
|
"i want",
|
||||||
|
"on doit",
|
||||||
|
"we need",
|
||||||
|
"refondre",
|
||||||
|
"before coding",
|
||||||
|
],
|
||||||
|
"aliases": ["summarize", "summary", "brief"],
|
||||||
|
"preferred_cli": "claude",
|
||||||
|
"preferred_agent": "planner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "architecture-brainstorm",
|
||||||
|
"label": "Architecture Brainstorm",
|
||||||
|
"layer": "reflection",
|
||||||
|
"visibility": "public",
|
||||||
|
"summary": "Comparer plusieurs options d'architecture avant de choisir.",
|
||||||
|
"when_to_use": [
|
||||||
|
"quand plusieurs approches sont possibles",
|
||||||
|
"quand on veut comparer les trade-offs",
|
||||||
|
"quand il faut une vraie discussion d'architecture",
|
||||||
|
],
|
||||||
|
"input_examples": [
|
||||||
|
"Doit-on faire un backend event-driven ou un monolithe modulaire ?",
|
||||||
|
"Quel runtime pour le cockpit interactif ?",
|
||||||
|
],
|
||||||
|
"output_expected": [
|
||||||
|
"options",
|
||||||
|
"avantages",
|
||||||
|
"inconvenients",
|
||||||
|
"complexite",
|
||||||
|
"impacts_perf",
|
||||||
|
"impacts_securite",
|
||||||
|
"impacts_dx",
|
||||||
|
"recommendation",
|
||||||
|
"raisons_du_choix",
|
||||||
|
],
|
||||||
|
"usage_prompt": (
|
||||||
|
"Analyse ce probleme comme un review partner senior. "
|
||||||
|
"Propose plusieurs architectures possibles, compare les compromis, "
|
||||||
|
"signale les pieges, puis recommande une option avec justification."
|
||||||
|
),
|
||||||
|
"keywords": [
|
||||||
|
"architecture",
|
||||||
|
"brainstorm",
|
||||||
|
"architecture-brainstorm",
|
||||||
|
"trade-off",
|
||||||
|
"tradeoff",
|
||||||
|
"option",
|
||||||
|
"compare",
|
||||||
|
"alternatives",
|
||||||
|
"design choice",
|
||||||
|
"choix technique",
|
||||||
|
],
|
||||||
|
"aliases": ["brainstorm"],
|
||||||
|
"preferred_cli": "vibe",
|
||||||
|
"preferred_agent": "analyst",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repo-onboarding",
|
||||||
|
"label": "Repo Onboarding",
|
||||||
|
"layer": "execution-prep",
|
||||||
|
"visibility": "public",
|
||||||
|
"summary": "Comprendre rapidement un repo avant la premiere intervention.",
|
||||||
|
"when_to_use": [
|
||||||
|
"quand on arrive sur un nouveau projet",
|
||||||
|
"quand il faut cartographier une codebase",
|
||||||
|
"quand on veut savoir ou modifier sans casser le reste",
|
||||||
|
],
|
||||||
|
"input_examples": [
|
||||||
|
"Explore ce repo comme si tu preparais une premiere intervention",
|
||||||
|
"Dis-moi ou intervenir pour cette tache",
|
||||||
|
],
|
||||||
|
"output_expected": [
|
||||||
|
"structure_repo",
|
||||||
|
"points_entree",
|
||||||
|
"modules_cles",
|
||||||
|
"conventions",
|
||||||
|
"commandes_utiles",
|
||||||
|
"zones_a_risque",
|
||||||
|
"strategie_modification",
|
||||||
|
],
|
||||||
|
"usage_prompt": (
|
||||||
|
"Explore ce repo comme si tu preparais une premiere intervention. "
|
||||||
|
"Resume sa structure, les modules critiques, les conventions implicites, "
|
||||||
|
"les commandes de dev/test, puis dis ou intervenir pour cette tache."
|
||||||
|
),
|
||||||
|
"keywords": [
|
||||||
|
"repo onboarding",
|
||||||
|
"repo-onboarding",
|
||||||
|
"onboarding",
|
||||||
|
"explore repo",
|
||||||
|
"new repo",
|
||||||
|
"codebase",
|
||||||
|
"structure du repo",
|
||||||
|
"points d'entree",
|
||||||
|
"where to change",
|
||||||
|
"cartographier",
|
||||||
|
],
|
||||||
|
"aliases": [],
|
||||||
|
"preferred_cli": "claude",
|
||||||
|
"preferred_agent": "planner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "task-decomposer",
|
||||||
|
"label": "Task Decomposer",
|
||||||
|
"layer": "execution-prep",
|
||||||
|
"visibility": "public",
|
||||||
|
"summary": "Transformer une spec ou direction choisie en plan d'execution operationnel.",
|
||||||
|
"when_to_use": [
|
||||||
|
"quand la direction est choisie",
|
||||||
|
"quand on veut passer du brainstorming a l'action",
|
||||||
|
"quand il faut un ordre d'execution clair",
|
||||||
|
],
|
||||||
|
"input_examples": [
|
||||||
|
"Decoupe cette refonte en sous-taches concretement executables",
|
||||||
|
"Fais-moi un plan d'implementation directement actionnable",
|
||||||
|
],
|
||||||
|
"output_expected": [
|
||||||
|
"etapes_ordonnees",
|
||||||
|
"dependances",
|
||||||
|
"quick_wins",
|
||||||
|
"parallelisation",
|
||||||
|
"tests",
|
||||||
|
"definition_of_done",
|
||||||
|
],
|
||||||
|
"usage_prompt": (
|
||||||
|
"Decoupe cette tache en sous-taches concretes, ordonnees, avec dependances, "
|
||||||
|
"risques, validations et tests associes. "
|
||||||
|
"Le plan doit etre directement executable par un agent de code."
|
||||||
|
),
|
||||||
|
"keywords": [
|
||||||
|
"task decomposer",
|
||||||
|
"task-decomposer",
|
||||||
|
"decompose",
|
||||||
|
"plan d'execution",
|
||||||
|
"execution plan",
|
||||||
|
"break down",
|
||||||
|
"subtasks",
|
||||||
|
"steps",
|
||||||
|
"roadmap",
|
||||||
|
"implementation plan",
|
||||||
|
],
|
||||||
|
"aliases": [],
|
||||||
|
"preferred_cli": "claude",
|
||||||
|
"preferred_agent": "planner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pr-review-prep",
|
||||||
|
"label": "PR Review Prep",
|
||||||
|
"layer": "execution-prep",
|
||||||
|
"visibility": "public",
|
||||||
|
"summary": "Preparer une PR review-ready et anticiper les objections du reviewer.",
|
||||||
|
"when_to_use": [
|
||||||
|
"quand l'implementation est terminee",
|
||||||
|
"quand il faut une bonne PR",
|
||||||
|
"quand on veut preparer la review",
|
||||||
|
],
|
||||||
|
"input_examples": [
|
||||||
|
"Prepare une PR clean a partir de ces changements",
|
||||||
|
"Resume le pourquoi, le quoi et les risques pour le reviewer",
|
||||||
|
],
|
||||||
|
"output_expected": [
|
||||||
|
"resume_du_changement",
|
||||||
|
"impact_fonctionnel",
|
||||||
|
"impact_technique",
|
||||||
|
"migrations",
|
||||||
|
"tests",
|
||||||
|
"points_attention_reviewer",
|
||||||
|
"message_de_pr",
|
||||||
|
],
|
||||||
|
"usage_prompt": (
|
||||||
|
"Prepare une PR review-ready a partir de ces changements. "
|
||||||
|
"Resume le pourquoi, le quoi, les impacts, les risques, "
|
||||||
|
"les tests effectues et les points a surveiller pour le reviewer."
|
||||||
|
),
|
||||||
|
"keywords": [
|
||||||
|
"pr review prep",
|
||||||
|
"pr-review-prep",
|
||||||
|
"pr",
|
||||||
|
"pull request",
|
||||||
|
"review prep",
|
||||||
|
"release notes",
|
||||||
|
"review-ready",
|
||||||
|
"reviewer",
|
||||||
|
],
|
||||||
|
"aliases": ["review"],
|
||||||
|
"preferred_cli": "claude",
|
||||||
|
"preferred_agent": "planner",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "implementation",
|
||||||
|
"label": "Implementation",
|
||||||
|
"layer": "execution",
|
||||||
|
"visibility": "internal",
|
||||||
|
"summary": "Ship code changes and concrete fixes.",
|
||||||
|
"when_to_use": [
|
||||||
|
"quand il faut modifier du code maintenant",
|
||||||
|
"quand la spec est deja suffisamment claire",
|
||||||
|
],
|
||||||
|
"input_examples": ["Fix the flaky test", "Implement the API endpoint"],
|
||||||
|
"output_expected": ["code_changes", "validation", "tests"],
|
||||||
|
"usage_prompt": "Implement the requested change directly and validate it.",
|
||||||
|
"keywords": ["fix", "implement", "build", "write", "patch", "feature", "code"],
|
||||||
|
"aliases": [],
|
||||||
|
"preferred_cli": "codex",
|
||||||
|
"preferred_agent": "executor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "debug-restoration",
|
||||||
|
"label": "Debug Restoration",
|
||||||
|
"layer": "execution",
|
||||||
|
"visibility": "internal",
|
||||||
|
"summary": "Investigate failures and restore a working state.",
|
||||||
|
"when_to_use": [
|
||||||
|
"quand quelque chose casse",
|
||||||
|
"quand il faut comprendre puis restaurer un etat sain",
|
||||||
|
],
|
||||||
|
"input_examples": ["The pane dies instantly", "This build started failing today"],
|
||||||
|
"output_expected": ["root_cause", "fix", "validation"],
|
||||||
|
"usage_prompt": "Investigate the failure, isolate the cause, and restore a working state.",
|
||||||
|
"keywords": ["bug", "debug", "failure", "broken", "error", "crash", "repair"],
|
||||||
|
"aliases": ["debug"],
|
||||||
|
"preferred_cli": "codex",
|
||||||
|
"preferred_agent": "executor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ops-deployment",
|
||||||
|
"label": "Ops Deployment",
|
||||||
|
"layer": "execution",
|
||||||
|
"visibility": "internal",
|
||||||
|
"summary": "Handle shell, fleet, infra, network, and remote machine work.",
|
||||||
|
"when_to_use": [
|
||||||
|
"quand il faut deployer, scanner, installer, diagnostiquer des machines",
|
||||||
|
"quand la tache est principalement shell/infra",
|
||||||
|
],
|
||||||
|
"input_examples": ["Deploy the runtime on all machines", "Scan SSH hosts and configure the fleet"],
|
||||||
|
"output_expected": ["ops_actions", "machine_state", "validation"],
|
||||||
|
"usage_prompt": "Handle the shell, infra, fleet, and remote machine operations safely.",
|
||||||
|
"keywords": ["ssh", "shell", "fleet", "ops", "network", "infra", "deploy"],
|
||||||
|
"aliases": ["ops"],
|
||||||
|
"preferred_cli": "codex",
|
||||||
|
"preferred_agent": "executor",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_skill_id(value: str) -> str:
|
||||||
|
return value.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_entries(include_internal: bool = True) -> list[dict[str, Any]]:
|
||||||
|
if include_internal:
|
||||||
|
return DEFAULT_SKILLS
|
||||||
|
return [skill for skill in DEFAULT_SKILLS if skill.get("visibility") != "internal"]
|
||||||
|
|
||||||
|
|
||||||
|
def list_agents() -> list[dict[str, Any]]:
|
||||||
|
return [dict(item) for item in DEFAULT_AGENTS]
|
||||||
|
|
||||||
|
|
||||||
|
def list_skills(include_internal: bool = True) -> list[dict[str, Any]]:
|
||||||
|
return [dict(item) for item in _skill_entries(include_internal=include_internal)]
|
||||||
|
|
||||||
|
|
||||||
|
def skill_catalog(include_internal: bool = False) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
payload = {"reflection": [], "execution-prep": [], "execution": [], "manual": []}
|
||||||
|
for skill in _skill_entries(include_internal=include_internal):
|
||||||
|
payload.setdefault(str(skill["layer"]), []).append(dict(skill))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def skill_catalog_brief(include_internal: bool = False) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": skill["id"],
|
||||||
|
"layer": skill["layer"],
|
||||||
|
"summary": skill["summary"],
|
||||||
|
"preferred_cli": skill["preferred_cli"],
|
||||||
|
"preferred_agent": skill["preferred_agent"],
|
||||||
|
}
|
||||||
|
for skill in _skill_entries(include_internal=include_internal)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def recommended_skill_stack() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"minimal": ["spec-planner", "architecture-brainstorm", "task-decomposer"],
|
||||||
|
"workflow": [
|
||||||
|
"spec-planner",
|
||||||
|
"architecture-brainstorm",
|
||||||
|
"repo-onboarding",
|
||||||
|
"task-decomposer",
|
||||||
|
"pr-review-prep",
|
||||||
|
],
|
||||||
|
"layers": {
|
||||||
|
"reflection": ["spec-planner", "architecture-brainstorm"],
|
||||||
|
"execution": ["repo-onboarding", "task-decomposer", "pr-review-prep"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def agent_by_id(agent_id: str) -> dict[str, Any]:
|
||||||
|
for agent in DEFAULT_AGENTS:
|
||||||
|
if agent["id"] == agent_id:
|
||||||
|
return dict(agent)
|
||||||
|
raise KeyError(f"Unknown agent: {agent_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def agent_for_cli(cli: str) -> dict[str, Any]:
|
||||||
|
for agent in DEFAULT_AGENTS:
|
||||||
|
if agent["primary_cli"] == cli:
|
||||||
|
return dict(agent)
|
||||||
|
return agent_by_id("executor")
|
||||||
|
|
||||||
|
|
||||||
|
def agent_hint(agent_id: str) -> dict[str, Any]:
|
||||||
|
return agent_by_id(agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def skill_by_id(skill_id: str, include_internal: bool = True) -> dict[str, Any]:
|
||||||
|
normalized = _normalize_skill_id(skill_id)
|
||||||
|
for skill in _skill_entries(include_internal=include_internal):
|
||||||
|
aliases = [_normalize_skill_id(alias) for alias in skill.get("aliases", [])]
|
||||||
|
if normalized == skill["id"] or normalized in aliases:
|
||||||
|
return dict(skill)
|
||||||
|
raise KeyError(f"Unknown skill: {skill_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def _score_keywords(goal_l: str, skill: dict[str, Any]) -> int:
|
||||||
|
score = 0
|
||||||
|
for keyword in skill.get("keywords", []):
|
||||||
|
if keyword in goal_l:
|
||||||
|
score += 3 if " " in keyword or "-" in keyword else 1
|
||||||
|
if skill["id"] in goal_l:
|
||||||
|
score += 5
|
||||||
|
for alias in skill.get("aliases", []):
|
||||||
|
if alias in goal_l:
|
||||||
|
score += 3
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def match_skill(goal: str, include_internal: bool = True) -> dict[str, Any]:
|
||||||
|
goal_l = goal.lower()
|
||||||
|
best = skill_by_id("implementation", include_internal=include_internal) if include_internal else skill_by_id("spec-planner", include_internal=False)
|
||||||
|
best_score = -1
|
||||||
|
for skill in _skill_entries(include_internal=include_internal):
|
||||||
|
score = _score_keywords(goal_l, skill)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best = skill
|
||||||
|
if best_score <= 0:
|
||||||
|
if any(token in goal_l for token in ("repo", "codebase", "onboarding", "where to change", "first intervention")):
|
||||||
|
return skill_by_id("repo-onboarding", include_internal=include_internal)
|
||||||
|
if any(token in goal_l for token in ("je veux", "i want", "on doit", "we need", "idea brute", "high level idea")):
|
||||||
|
return skill_by_id("spec-planner", include_internal=include_internal)
|
||||||
|
if any(token in goal_l for token in ("architecture", "trade-off", "tradeoff", "option", "compare")):
|
||||||
|
return skill_by_id("architecture-brainstorm", include_internal=include_internal)
|
||||||
|
if any(token in goal_l for token in ("plan", "sub-task", "subtask", "decompose", "step-by-step", "roadmap")):
|
||||||
|
return skill_by_id("task-decomposer", include_internal=include_internal)
|
||||||
|
if any(token in goal_l for token in ("pr", "pull request", "reviewer", "release notes")):
|
||||||
|
return skill_by_id("pr-review-prep", include_internal=include_internal)
|
||||||
|
if include_internal and any(token in goal_l for token in ("bug", "crash", "failure", "broken", "error")):
|
||||||
|
return skill_by_id("debug-restoration", include_internal=True)
|
||||||
|
if include_internal and any(token in goal_l for token in ("ssh", "fleet", "deploy", "network", "machine", "infra")):
|
||||||
|
return skill_by_id("ops-deployment", include_internal=True)
|
||||||
|
return best
|
||||||
|
return dict(best)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_skill_and_agent(
|
||||||
|
*,
|
||||||
|
goal: str | None = None,
|
||||||
|
skill_id: str | None = None,
|
||||||
|
agent_id: str | None = None,
|
||||||
|
cli: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
skill = skill_by_id(skill_id) if skill_id else match_skill(goal or "")
|
||||||
|
if cli:
|
||||||
|
agent = agent_for_cli(cli)
|
||||||
|
elif agent_id:
|
||||||
|
agent = agent_by_id(agent_id)
|
||||||
|
cli = str(agent["primary_cli"])
|
||||||
|
else:
|
||||||
|
agent = agent_by_id(str(skill["preferred_agent"]))
|
||||||
|
cli = str(skill["preferred_cli"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"skill": skill,
|
||||||
|
"agent": agent,
|
||||||
|
"cli": cli,
|
||||||
|
}
|
||||||
+165
-7
@@ -3,17 +3,28 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
from .capabilities import (
|
||||||
|
agent_for_cli,
|
||||||
|
list_agents,
|
||||||
|
list_skills,
|
||||||
|
recommended_skill_stack,
|
||||||
|
resolve_skill_and_agent,
|
||||||
|
skill_catalog,
|
||||||
|
skill_by_id,
|
||||||
|
)
|
||||||
from .cockpit import capture_pane, cockpit_doctor, focus_machine, restart_pane, runtime_status, send_to_pane
|
from .cockpit import capture_pane, cockpit_doctor, focus_machine, restart_pane, runtime_status, send_to_pane
|
||||||
from .daemon import daemon_status, request as daemon_request, serve_foreground, start_background, stop_background
|
from .daemon import daemon_status, request as daemon_request, serve_foreground, start_background, stop_background
|
||||||
from .executors import bridge_sync, execute_step, fleet_check
|
from .executors import bridge_sync, execute_step, fleet_check
|
||||||
from .memory import (
|
from .memory import (
|
||||||
enroll_workspace,
|
enroll_workspace,
|
||||||
|
instruction_skill_sources,
|
||||||
list_decisions,
|
list_decisions,
|
||||||
memory_status,
|
memory_status,
|
||||||
persona_markdown,
|
persona_markdown,
|
||||||
@@ -56,6 +67,96 @@ def _command_exists(binary: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _read_wrapper_stamp(path: Path) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
raw = path.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
parts = raw.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
return {"raw": raw}
|
||||||
|
signature, epoch_text = parts[0], parts[1]
|
||||||
|
payload: dict[str, Any] = {"signature": signature, "epoch": epoch_text}
|
||||||
|
try:
|
||||||
|
payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(epoch_text)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _read_wrapper_mode(path: Path) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
raw = path.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
parts = raw.split()
|
||||||
|
if len(parts) < 3:
|
||||||
|
return {"raw": raw}
|
||||||
|
mode, reason, epoch_text = parts[0], parts[1], parts[2]
|
||||||
|
payload: dict[str, Any] = {"mode": mode, "reason": reason, "epoch": epoch_text}
|
||||||
|
try:
|
||||||
|
payload["timestamp"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(epoch_text)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _run_quick_command(command: list[str]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=3,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"ok": False, "error": str(exc), "command": command}
|
||||||
|
return {
|
||||||
|
"ok": completed.returncode == 0,
|
||||||
|
"returncode": completed.returncode,
|
||||||
|
"stdout": completed.stdout.strip(),
|
||||||
|
"stderr": completed.stderr.strip(),
|
||||||
|
"command": command,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wrapper_status() -> dict[str, Any]:
|
||||||
|
wrapper_dir = cache_root() / "wrapper"
|
||||||
|
rust_bin = repo_root() / "target" / "debug" / "constant"
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"wrapper_dir": str(wrapper_dir),
|
||||||
|
"rust_bin": str(rust_bin),
|
||||||
|
"rust_bin_exists": rust_bin.exists(),
|
||||||
|
"forced_python": os.environ.get("CONSTANT_USE_PYTHON") == "1",
|
||||||
|
"forced_rust": os.environ.get("CONSTANT_USE_RUST") == "1",
|
||||||
|
"force_recheck": os.environ.get("CONSTANT_RUST_RECHECK") == "1",
|
||||||
|
"last_mode": _read_wrapper_mode(wrapper_dir / "last-mode"),
|
||||||
|
"probe_timeout_sec": os.environ.get("CONSTANT_RUST_PROBE_TIMEOUT_SEC", "2"),
|
||||||
|
"probe_fail_ttl_sec": os.environ.get("CONSTANT_RUST_PROBE_FAIL_TTL_SEC", "300"),
|
||||||
|
"cache": {
|
||||||
|
"rust_ok": _read_wrapper_stamp(wrapper_dir / "rust-ok"),
|
||||||
|
"rust_fail": _read_wrapper_stamp(wrapper_dir / "rust-fail"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if rust_bin.exists():
|
||||||
|
payload["codesign_verify"] = _run_quick_command(
|
||||||
|
["codesign", "--verify", "--verbose=2", str(rust_bin)]
|
||||||
|
)
|
||||||
|
payload["spctl_assess"] = _run_quick_command(["spctl", "--assess", "-vv", str(rust_bin)])
|
||||||
|
payload["xattr_provenance"] = _run_quick_command(["xattr", "-p", "com.apple.provenance", str(rust_bin)])
|
||||||
|
payload["hint"] = (
|
||||||
|
"If spctl reports a Code Signing subsystem error from a normal terminal, allow that terminal in "
|
||||||
|
"System Settings -> Privacy & Security -> Developer Tools. Then rerun with CONSTANT_RUST_RECHECK=1 "
|
||||||
|
"to force a fresh startup probe."
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _warm_memory(workspace: str) -> dict[str, Any] | None:
|
def _warm_memory(workspace: str) -> dict[str, Any] | None:
|
||||||
try:
|
try:
|
||||||
return rebuild_workspace_memory(workspace, enroll=True)
|
return rebuild_workspace_memory(workspace, enroll=True)
|
||||||
@@ -99,6 +200,9 @@ def _mission_summary(mission: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"backend": step["backend"],
|
"backend": step["backend"],
|
||||||
"cli": step["cli"],
|
"cli": step["cli"],
|
||||||
"agent": step["agent"],
|
"agent": step["agent"],
|
||||||
|
"agent_role": step.get("agent_role"),
|
||||||
|
"skill": step.get("skill"),
|
||||||
|
"skill_summary": step.get("skill_summary"),
|
||||||
"attempt": step["attempt"],
|
"attempt": step["attempt"],
|
||||||
}
|
}
|
||||||
for step in mission["steps"]
|
for step in mission["steps"]
|
||||||
@@ -133,6 +237,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
|
|||||||
"memory": memory_status(),
|
"memory": memory_status(),
|
||||||
"fleet": [{"label": entry["label"], "target": entry["target"]} for entry in fleet["machines"]],
|
"fleet": [{"label": entry["label"], "target": entry["target"]} for entry in fleet["machines"]],
|
||||||
"cockpit": cockpit,
|
"cockpit": cockpit,
|
||||||
|
"wrapper": _wrapper_status(),
|
||||||
}
|
}
|
||||||
_print(report, args.json)
|
_print(report, args.json)
|
||||||
return 0
|
return 0
|
||||||
@@ -178,6 +283,24 @@ def cmd_models_status(args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_agents(args: argparse.Namespace) -> int:
|
||||||
|
payload = {"agents": list_agents(), "recommended_skill_stack": recommended_skill_stack()}
|
||||||
|
_print(payload, args.json)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_skills(args: argparse.Namespace) -> int:
|
||||||
|
payload = {
|
||||||
|
"skills": list_skills(include_internal=not getattr(args, "public_only", False)),
|
||||||
|
"catalog": skill_catalog(include_internal=not getattr(args, "public_only", False)),
|
||||||
|
"recommended_skill_stack": recommended_skill_stack(),
|
||||||
|
}
|
||||||
|
if getattr(args, "workspace", None):
|
||||||
|
payload["instruction_skill_sources"] = instruction_skill_sources(args.workspace)
|
||||||
|
_print(payload, args.json)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_fleet_status(args: argparse.Namespace) -> int:
|
def cmd_fleet_status(args: argparse.Namespace) -> int:
|
||||||
payload = fleet_check()
|
payload = fleet_check()
|
||||||
_print(payload, args.json)
|
_print(payload, args.json)
|
||||||
@@ -491,14 +614,38 @@ def cmd_delegate(args: argparse.Namespace) -> int:
|
|||||||
step["machine"] = args.machine
|
step["machine"] = args.machine
|
||||||
if args.backend:
|
if args.backend:
|
||||||
step["backend"] = args.backend
|
step["backend"] = args.backend
|
||||||
if args.cli:
|
if args.skill or args.agent or args.cli:
|
||||||
step["cli"] = args.cli
|
resolved_cli = args.cli
|
||||||
if args.agent:
|
resolved_agent = args.agent
|
||||||
step["agent"] = args.agent
|
if not args.skill and not resolved_cli:
|
||||||
|
resolved_cli = step.get("cli")
|
||||||
|
if not args.skill and not resolved_agent:
|
||||||
|
resolved_agent = step.get("agent")
|
||||||
|
resolved = resolve_skill_and_agent(
|
||||||
|
goal=step.get("prompt") or mission.get("goal", ""),
|
||||||
|
skill_id=args.skill or step.get("skill"),
|
||||||
|
agent_id=resolved_agent,
|
||||||
|
cli=resolved_cli,
|
||||||
|
)
|
||||||
|
step["skill"] = resolved["skill"]["id"]
|
||||||
|
step["skill_summary"] = resolved["skill"]["summary"]
|
||||||
|
step["agent"] = resolved["agent"]["id"]
|
||||||
|
step["agent_role"] = resolved["agent"]["role"]
|
||||||
|
step["cli"] = resolved["cli"]
|
||||||
|
elif step.get("cli") and not step.get("agent"):
|
||||||
|
agent = agent_for_cli(step["cli"])
|
||||||
|
step["agent"] = agent["id"]
|
||||||
|
step["agent_role"] = agent["role"]
|
||||||
|
|
||||||
|
if step.get("skill"):
|
||||||
|
try:
|
||||||
|
step["skill_summary"] = skill_by_id(step["skill"])["summary"]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
step["status"] = "pending"
|
step["status"] = "pending"
|
||||||
mission["status"] = "planned"
|
mission["status"] = "planned"
|
||||||
save_mission(mission)
|
save_mission(mission)
|
||||||
append_event(mission["mission_id"], "step.delegated", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"], "agent": step["agent"]})
|
append_event(mission["mission_id"], "step.delegated", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"], "agent": step["agent"], "skill": step.get("skill")})
|
||||||
_print(_mission_summary(mission), args.json)
|
_print(_mission_summary(mission), args.json)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -557,7 +704,7 @@ def cmd_mission_summarize(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(prog="Constant")
|
parser = argparse.ArgumentParser(prog=os.environ.get("CONSTANT_PROG_NAME", "Constant"))
|
||||||
parser.add_argument("-V", "--version", action="version", version=__version__)
|
parser.add_argument("-V", "--version", action="version", version=__version__)
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
@@ -589,6 +736,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
models_status.add_argument("--json", action="store_true")
|
models_status.add_argument("--json", action="store_true")
|
||||||
models_status.set_defaults(func=cmd_models_status)
|
models_status.set_defaults(func=cmd_models_status)
|
||||||
|
|
||||||
|
agents = subparsers.add_parser("agents")
|
||||||
|
agents.add_argument("--json", action="store_true")
|
||||||
|
agents.set_defaults(func=cmd_agents)
|
||||||
|
|
||||||
|
skills = subparsers.add_parser("skills")
|
||||||
|
skills.add_argument("--workspace")
|
||||||
|
skills.add_argument("--public-only", action="store_true")
|
||||||
|
skills.add_argument("--json", action="store_true")
|
||||||
|
skills.set_defaults(func=cmd_skills)
|
||||||
|
|
||||||
fleet = subparsers.add_parser("fleet")
|
fleet = subparsers.add_parser("fleet")
|
||||||
fleet_sub = fleet.add_subparsers(dest="fleet_command", required=True)
|
fleet_sub = fleet.add_subparsers(dest="fleet_command", required=True)
|
||||||
fleet_status_cmd = fleet_sub.add_parser("status")
|
fleet_status_cmd = fleet_sub.add_parser("status")
|
||||||
@@ -715,6 +872,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
delegate.add_argument("--backend")
|
delegate.add_argument("--backend")
|
||||||
delegate.add_argument("--cli")
|
delegate.add_argument("--cli")
|
||||||
delegate.add_argument("--agent")
|
delegate.add_argument("--agent")
|
||||||
|
delegate.add_argument("--skill")
|
||||||
delegate.add_argument("--json", action="store_true")
|
delegate.add_argument("--json", action="store_true")
|
||||||
delegate.set_defaults(func=cmd_delegate)
|
delegate.set_defaults(func=cmd_delegate)
|
||||||
|
|
||||||
@@ -773,7 +931,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
argv = sys.argv[1:]
|
argv = sys.argv[1:]
|
||||||
if not argv:
|
if not argv:
|
||||||
if sys.stdin.isatty() and sys.stdout.isatty():
|
if sys.stdin.isatty() and sys.stdout.isatty():
|
||||||
argv = ["tui", "--workspace", os.getcwd()]
|
argv = ["cockpit", "open", "--workspace", os.getcwd()]
|
||||||
else:
|
else:
|
||||||
argv = ["doctor"]
|
argv = ["doctor"]
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
|
|||||||
+29
-4
@@ -112,14 +112,34 @@ def _machine_tmux_status(machine: dict[str, Any], session: str) -> dict[str, Any
|
|||||||
|
|
||||||
def runtime_status(local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any]:
|
def runtime_status(local_session: str = "constant-fleet", machine_session: str = "constant") -> dict[str, Any]:
|
||||||
fleet = load_fleet_config()
|
fleet = load_fleet_config()
|
||||||
local_tmux = _run(["tmux", "list-windows", "-t", local_session, "-F", "#{window_name}"])
|
local_tmux = _run(["tmux", "list-windows", "-t", local_session, "-F", "#{window_name}\t#{window_active}"])
|
||||||
fleet_windows = local_tmux["stdout"].splitlines() if local_tmux["returncode"] == 0 else []
|
fleet_windows: list[str] = []
|
||||||
|
focused_machine = None
|
||||||
|
if local_tmux["returncode"] == 0:
|
||||||
|
for raw in local_tmux["stdout"].splitlines():
|
||||||
|
window_name, _, *rest = raw.split("\t") + [""]
|
||||||
|
active = raw.split("\t")[1] if "\t" in raw else "0"
|
||||||
|
fleet_windows.append(window_name)
|
||||||
|
if active == "1":
|
||||||
|
focused_machine = window_name
|
||||||
machines = [_machine_tmux_status(machine, machine_session) for machine in fleet["machines"]]
|
machines = [_machine_tmux_status(machine, machine_session) for machine in fleet["machines"]]
|
||||||
|
focused_role = None
|
||||||
|
for machine in machines:
|
||||||
|
if machine["label"] != focused_machine:
|
||||||
|
continue
|
||||||
|
for role in ROLES:
|
||||||
|
pane = machine["roles"].get(role)
|
||||||
|
if pane and pane.get("active"):
|
||||||
|
focused_role = role
|
||||||
|
break
|
||||||
|
break
|
||||||
return {
|
return {
|
||||||
"local_session": local_session,
|
"local_session": local_session,
|
||||||
"machine_session": machine_session,
|
"machine_session": machine_session,
|
||||||
"fleet_session_exists": local_tmux["returncode"] == 0,
|
"fleet_session_exists": local_tmux["returncode"] == 0,
|
||||||
"fleet_windows": fleet_windows,
|
"fleet_windows": fleet_windows,
|
||||||
|
"focused_machine": focused_machine,
|
||||||
|
"focused_role": focused_role,
|
||||||
"machines": machines,
|
"machines": machines,
|
||||||
"fleet_stderr": local_tmux["stderr"].strip(),
|
"fleet_stderr": local_tmux["stderr"].strip(),
|
||||||
}
|
}
|
||||||
@@ -142,13 +162,18 @@ def _machine_control_script(machine_label: str, machine_session: str) -> tuple[d
|
|||||||
return machine, script
|
return machine, script
|
||||||
|
|
||||||
|
|
||||||
|
def _machine_command_args(machine: dict[str, Any], args: list[str]) -> list[str]:
|
||||||
|
return ["env", f"ZELLIJ_AI_MACHINE_NAME={machine['label']}", *args]
|
||||||
|
|
||||||
|
|
||||||
def _run_machine_command(machine: dict[str, Any], args: list[str]) -> dict[str, Any]:
|
def _run_machine_command(machine: dict[str, Any], args: list[str]) -> dict[str, Any]:
|
||||||
|
command_args = _machine_command_args(machine, args)
|
||||||
target = machine["target"]
|
target = machine["target"]
|
||||||
local_names = {socket.gethostname(), socket.getfqdn(), socket.gethostname().split(".")[0], "local", "localhost", "127.0.0.1", "::1"}
|
local_names = {socket.gethostname(), socket.getfqdn(), socket.gethostname().split(".")[0], "local", "localhost", "127.0.0.1", "::1"}
|
||||||
if target in local_names:
|
if target in local_names:
|
||||||
return _run(args)
|
return _run(command_args)
|
||||||
|
|
||||||
quoted = shlex.join(args)
|
quoted = shlex.join(command_args)
|
||||||
return _run(_ssh_command(target, quoted))
|
return _run(_ssh_command(target, quoted))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ class _Handler(socketserver.StreamRequestHandler):
|
|||||||
payload = self.engine.verify_step(request["mission"], request["step"], request["execution"])
|
payload = self.engine.verify_step(request["mission"], request["step"], request["execution"])
|
||||||
elif op == "buddy":
|
elif op == "buddy":
|
||||||
payload = self.engine.buddy_ask(request.get("mission"), request["prompt"])
|
payload = self.engine.buddy_ask(request.get("mission"), request["prompt"])
|
||||||
|
elif op == "chat":
|
||||||
|
payload = self.engine.chat(
|
||||||
|
request["message"],
|
||||||
|
request.get("mission"),
|
||||||
|
request["workspace"],
|
||||||
|
request.get("selected_machine"),
|
||||||
|
request.get("selected_role"),
|
||||||
|
request.get("chat_history"),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise KeyError(f"Unsupported operation: {op}")
|
raise KeyError(f"Unsupported operation: {op}")
|
||||||
response = {"ok": True, "payload": payload}
|
response = {"ok": True, "payload": payload}
|
||||||
@@ -188,6 +197,15 @@ def _direct_request(op: str, payload: dict[str, Any] | None = None) -> dict[str,
|
|||||||
request_payload.get("mission"),
|
request_payload.get("mission"),
|
||||||
request_payload["prompt"],
|
request_payload["prompt"],
|
||||||
)
|
)
|
||||||
|
if op == "chat":
|
||||||
|
return _INLINE_ENGINE.chat(
|
||||||
|
request_payload["message"],
|
||||||
|
request_payload.get("mission"),
|
||||||
|
request_payload["workspace"],
|
||||||
|
request_payload.get("selected_machine"),
|
||||||
|
request_payload.get("selected_role"),
|
||||||
|
request_payload.get("chat_history"),
|
||||||
|
)
|
||||||
raise RuntimeError(f"Unsupported operation: {op}")
|
raise RuntimeError(f"Unsupported operation: {op}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+51
-3
@@ -256,8 +256,11 @@ def _detect_repo_root(workspace: Path) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _sanitize_fts_query(query: str) -> str:
|
def _sanitize_fts_query(query: str) -> str:
|
||||||
tokens = re.findall(r"[A-Za-z0-9_.:/-]+", query.lower())
|
raw_tokens = re.findall(r"[A-Za-z0-9_]+", query.lower())
|
||||||
return " ".join(tokens[:12]) or "constant"
|
tokens = [token for token in raw_tokens if len(token) >= 2][:12]
|
||||||
|
if not tokens:
|
||||||
|
return "\"constant\""
|
||||||
|
return " ".join(f"\"{token}\"" for token in tokens)
|
||||||
|
|
||||||
|
|
||||||
def _tokenize(text: str) -> list[str]:
|
def _tokenize(text: str) -> list[str]:
|
||||||
@@ -820,7 +823,11 @@ def search_memory(query: str, workspace: str | None = None, limit: int | None =
|
|||||||
params.append(workspace_path)
|
params.append(workspace_path)
|
||||||
chunk_sql += " order by rank limit ?"
|
chunk_sql += " order by rank limit ?"
|
||||||
params.append(max_hits * 3)
|
params.append(max_hits * 3)
|
||||||
for row in connection.execute(chunk_sql, tuple(params)).fetchall():
|
try:
|
||||||
|
rows = connection.execute(chunk_sql, tuple(params)).fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
rows = []
|
||||||
|
for row in rows:
|
||||||
hits.append(
|
hits.append(
|
||||||
{
|
{
|
||||||
"kind": "repo",
|
"kind": "repo",
|
||||||
@@ -918,6 +925,47 @@ def search_memory(query: str, workspace: str | None = None, limit: int | None =
|
|||||||
connection.close()
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def instruction_skill_sources(workspace: str | Path, query: str | None = None, limit: int = 6) -> list[dict[str, Any]]:
|
||||||
|
workspace_path = str(_normalize_workspace(workspace))
|
||||||
|
connection = _connect()
|
||||||
|
try:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
select path, scope, source_kind, weight, content, updated_at
|
||||||
|
from instruction_sources
|
||||||
|
where workspace = ? or scope = 'user'
|
||||||
|
order by weight desc, updated_at desc
|
||||||
|
limit 64
|
||||||
|
""",
|
||||||
|
(workspace_path,),
|
||||||
|
).fetchall()
|
||||||
|
hits: list[dict[str, Any]] = []
|
||||||
|
query_l = query.lower() if query else ""
|
||||||
|
query_tokens = _tokenize(query)[:6] if query else []
|
||||||
|
for row in rows:
|
||||||
|
path = row["path"]
|
||||||
|
score = float(row["weight"])
|
||||||
|
haystack = row["content"].lower()
|
||||||
|
if query:
|
||||||
|
matched = query_l in haystack or any(token in haystack for token in query_tokens) or any(token in path.lower() for token in query_tokens)
|
||||||
|
if not matched:
|
||||||
|
continue
|
||||||
|
score += 1.0
|
||||||
|
hits.append(
|
||||||
|
{
|
||||||
|
"path": path,
|
||||||
|
"scope": row["scope"],
|
||||||
|
"source_kind": row["source_kind"],
|
||||||
|
"weight": score,
|
||||||
|
"snippet": _snippet(row["content"], 160),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hits.sort(key=lambda item: item["weight"], reverse=True)
|
||||||
|
return hits[:limit]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
def persona_markdown() -> str:
|
def persona_markdown() -> str:
|
||||||
if persona_path().exists():
|
if persona_path().exists():
|
||||||
return persona_path().read_text(encoding="utf-8")
|
return persona_path().read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ def missions_dir() -> Path:
|
|||||||
return cache_root() / "missions"
|
return cache_root() / "missions"
|
||||||
|
|
||||||
|
|
||||||
|
def chat_root() -> Path:
|
||||||
|
return cache_root() / "chat"
|
||||||
|
|
||||||
|
|
||||||
def fleet_config_path() -> Path:
|
def fleet_config_path() -> Path:
|
||||||
return config_root() / "fleet.json"
|
return config_root() / "fleet.json"
|
||||||
|
|
||||||
|
|||||||
+353
-21
@@ -3,12 +3,27 @@ from __future__ import annotations
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .capabilities import (
|
||||||
|
agent_for_cli,
|
||||||
|
list_agents,
|
||||||
|
list_skills,
|
||||||
|
match_skill,
|
||||||
|
recommended_skill_stack,
|
||||||
|
resolve_skill_and_agent,
|
||||||
|
skill_by_id,
|
||||||
|
skill_catalog_brief,
|
||||||
|
)
|
||||||
|
from .memory import instruction_skill_sources, search_memory
|
||||||
from .state import load_fleet_config, load_models_config
|
from .state import load_fleet_config, load_models_config
|
||||||
|
|
||||||
|
CHAT_ROLES = ("claude", "codex", "copilot", "vibe")
|
||||||
|
|
||||||
|
|
||||||
def _fleet_labels() -> dict[str, str]:
|
def _fleet_labels() -> dict[str, str]:
|
||||||
fleet = load_fleet_config()
|
fleet = load_fleet_config()
|
||||||
@@ -47,9 +62,72 @@ def _lower(value: str) -> str:
|
|||||||
return value.lower()
|
return value.lower()
|
||||||
|
|
||||||
|
|
||||||
def _route_machine(goal: str) -> str:
|
def _public_skill_ids() -> list[str]:
|
||||||
|
return [item["id"] for item in list_skills(include_internal=False)]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_explicit_skill(message: str) -> tuple[str, dict[str, Any] | None]:
|
||||||
|
raw = message.strip()
|
||||||
|
lowered = raw.lower()
|
||||||
|
|
||||||
|
if lowered.startswith("skill:"):
|
||||||
|
payload = raw[len("skill:") :].strip()
|
||||||
|
if payload:
|
||||||
|
parts = payload.split(maxsplit=1)
|
||||||
|
try:
|
||||||
|
skill = skill_by_id(parts[0], include_internal=False)
|
||||||
|
except KeyError:
|
||||||
|
skill = None
|
||||||
|
if skill:
|
||||||
|
remainder = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
return remainder or raw, skill
|
||||||
|
|
||||||
|
if lowered.startswith("/skill "):
|
||||||
|
payload = raw[len("/skill ") :].strip()
|
||||||
|
if payload:
|
||||||
|
parts = payload.split(maxsplit=1)
|
||||||
|
try:
|
||||||
|
skill = skill_by_id(parts[0], include_internal=False)
|
||||||
|
except KeyError:
|
||||||
|
skill = None
|
||||||
|
if skill:
|
||||||
|
remainder = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
return remainder or raw, skill
|
||||||
|
|
||||||
|
if raw.startswith("/"):
|
||||||
|
parts = raw[1:].split(maxsplit=1)
|
||||||
|
if parts:
|
||||||
|
try:
|
||||||
|
skill = skill_by_id(parts[0], include_internal=False)
|
||||||
|
except KeyError:
|
||||||
|
skill = None
|
||||||
|
if skill:
|
||||||
|
remainder = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
return remainder or raw, skill
|
||||||
|
|
||||||
|
for skill_id in _public_skill_ids():
|
||||||
|
token = skill_id.lower()
|
||||||
|
if token in lowered:
|
||||||
|
try:
|
||||||
|
return raw, skill_by_id(skill_id, include_internal=False)
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
return raw, None
|
||||||
|
|
||||||
|
|
||||||
|
def _route_machine(goal: str, skill_id: str | None = None) -> str:
|
||||||
labels = _fleet_labels()
|
labels = _fleet_labels()
|
||||||
goal_l = _lower(goal)
|
goal_l = _lower(goal)
|
||||||
|
if skill_id in {"spec-planner", "repo-onboarding", "task-decomposer"}:
|
||||||
|
return labels["local"]
|
||||||
|
if skill_id == "architecture-brainstorm":
|
||||||
|
return labels["lab_a"]
|
||||||
|
if skill_id == "pr-review-prep":
|
||||||
|
return labels["builder_a"]
|
||||||
|
if skill_id == "ops-deployment":
|
||||||
|
return labels["edge_a"]
|
||||||
|
if skill_id == "debug-restoration" and any(token in goal_l for token in ("performance", "deep", "benchmark", "compiler", "cuda")):
|
||||||
|
return labels["builder_b"]
|
||||||
if any(token in goal_l for token in ("ssh", "shell", "fleet", "ops", "network", "infra")):
|
if any(token in goal_l for token in ("ssh", "shell", "fleet", "ops", "network", "infra")):
|
||||||
return labels["edge_a"]
|
return labels["edge_a"]
|
||||||
if any(token in goal_l for token in ("refactor", "performance", "deep", "cuda", "compiler", "benchmark")):
|
if any(token in goal_l for token in ("refactor", "performance", "deep", "cuda", "compiler", "benchmark")):
|
||||||
@@ -62,6 +140,9 @@ def _route_machine(goal: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _route_cli(goal: str) -> str:
|
def _route_cli(goal: str) -> str:
|
||||||
|
skill = match_skill(goal)
|
||||||
|
if skill.get("preferred_cli"):
|
||||||
|
return str(skill["preferred_cli"])
|
||||||
goal_l = _lower(goal)
|
goal_l = _lower(goal)
|
||||||
if any(token in goal_l for token in ("brainstorm", "idea", "alternative", "explore", "compare")):
|
if any(token in goal_l for token in ("brainstorm", "idea", "alternative", "explore", "compare")):
|
||||||
return "vibe"
|
return "vibe"
|
||||||
@@ -81,21 +162,26 @@ def _route_backend(machine: str, cli: str, goal: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _route_agent(cli: str) -> str:
|
def _route_agent(cli: str) -> str:
|
||||||
return {
|
return agent_for_cli(cli)["id"]
|
||||||
"claude": "planner",
|
|
||||||
"codex": "executor",
|
|
||||||
"vibe": "analyst",
|
|
||||||
"copilot": "assistant",
|
|
||||||
}.get(cli, "executor")
|
|
||||||
|
|
||||||
|
|
||||||
def _heuristic_plan(goal: str, workspace: str, mission_id: str) -> dict[str, Any]:
|
def _heuristic_plan(goal: str, workspace: str, mission_id: str, overrides: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
machine = _route_machine(goal)
|
overrides = overrides or {}
|
||||||
cli = _route_cli(goal)
|
resolved = resolve_skill_and_agent(
|
||||||
backend = _route_backend(machine, cli, goal)
|
goal=goal,
|
||||||
|
skill_id=overrides.get("skill"),
|
||||||
|
agent_id=overrides.get("agent"),
|
||||||
|
cli=overrides.get("cli"),
|
||||||
|
)
|
||||||
|
skill = resolved["skill"]
|
||||||
|
cli = str(resolved["cli"])
|
||||||
|
agent = resolved["agent"]
|
||||||
|
machine = str(overrides.get("machine") or _route_machine(goal, str(skill["id"])))
|
||||||
|
backend = str(overrides.get("backend") or _route_backend(machine, cli, goal))
|
||||||
|
skill_sources = instruction_skill_sources(workspace, query=goal, limit=4) if workspace else []
|
||||||
return {
|
return {
|
||||||
"title": goal.strip().splitlines()[0][:80] or mission_id,
|
"title": goal.strip().splitlines()[0][:80] or mission_id,
|
||||||
"summary": f"Route the mission to {machine} using {cli} via {backend}.",
|
"summary": f"Route the mission to {machine} using {cli} via {backend} for skill {skill['id']}.",
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
{
|
||||||
"step_id": "step-1",
|
"step_id": "step-1",
|
||||||
@@ -105,7 +191,11 @@ def _heuristic_plan(goal: str, workspace: str, mission_id: str) -> dict[str, Any
|
|||||||
"machine": machine,
|
"machine": machine,
|
||||||
"backend": backend,
|
"backend": backend,
|
||||||
"cli": cli,
|
"cli": cli,
|
||||||
"agent": _route_agent(cli),
|
"agent": agent["id"],
|
||||||
|
"agent_role": agent["role"],
|
||||||
|
"skill": skill["id"],
|
||||||
|
"skill_summary": skill["summary"],
|
||||||
|
"skill_sources": [item["path"] for item in skill_sources],
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"attempt": 0,
|
"attempt": 0,
|
||||||
"depends_on": [],
|
"depends_on": [],
|
||||||
@@ -187,12 +277,158 @@ def _heuristic_verify(step: dict[str, Any], execution: dict[str, Any]) -> dict[s
|
|||||||
|
|
||||||
def _heuristic_buddy_answer(prompt: str, mission: dict[str, Any] | None) -> dict[str, Any]:
|
def _heuristic_buddy_answer(prompt: str, mission: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
title = mission["title"] if mission else "mission"
|
title = mission["title"] if mission else "mission"
|
||||||
|
skill = match_skill(prompt)
|
||||||
return {
|
return {
|
||||||
"answer": f"Qwen buddy heuristic view for {title}: focus on route correctness, CLI fit, and whether codex should own the technical step. Prompt: {prompt}",
|
"answer": (
|
||||||
|
f"Qwen buddy heuristic view for {title}: "
|
||||||
|
f"skill={skill['id']}, focus on route correctness, CLI fit, and whether the selected agent matches the workflow stage. "
|
||||||
|
f"Prompt: {prompt}"
|
||||||
|
),
|
||||||
"mode": "heuristic",
|
"mode": "heuristic",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _match_machine_label(message: str) -> str | None:
|
||||||
|
message_l = _lower(message)
|
||||||
|
for machine in load_fleet_config()["machines"]:
|
||||||
|
label = machine["label"]
|
||||||
|
if label.lower() in message_l:
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _match_role(message: str) -> str | None:
|
||||||
|
message_l = _lower(message)
|
||||||
|
for role in CHAT_ROLES:
|
||||||
|
if role in message_l:
|
||||||
|
return role
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _heuristic_chat(
|
||||||
|
message: str,
|
||||||
|
mission: dict[str, Any] | None,
|
||||||
|
workspace: str,
|
||||||
|
selected_machine: str | None,
|
||||||
|
selected_role: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
prompt, explicit_skill = _extract_explicit_skill(message)
|
||||||
|
prompt = prompt.strip()
|
||||||
|
prompt_l = _lower(prompt)
|
||||||
|
memory_hits = search_memory(prompt, workspace=workspace, limit=4).get("hits", []) if workspace else []
|
||||||
|
skill_sources = instruction_skill_sources(workspace, query=prompt, limit=4) if workspace else []
|
||||||
|
memory_lines = [f"{hit['kind']} {hit['path']} :: {hit['snippet']}" for hit in memory_hits[:3]]
|
||||||
|
buddy_note = None
|
||||||
|
cockpit_action: dict[str, Any] | None = None
|
||||||
|
intent = "plain_chat"
|
||||||
|
|
||||||
|
target_machine = _match_machine_label(prompt) or selected_machine or _fleet_labels()["local"]
|
||||||
|
target_role = _match_role(prompt) or selected_role or "codex"
|
||||||
|
matched_skill = explicit_skill or (match_skill(prompt) if prompt else None)
|
||||||
|
|
||||||
|
if any(token in prompt_l for token in ("open cockpit", "attach cockpit", "show cockpit")):
|
||||||
|
intent = "cockpit_open"
|
||||||
|
cockpit_action = {"type": "open"}
|
||||||
|
reply = "I can hand off to the full cockpit now."
|
||||||
|
elif any(token in prompt_l for token in ("restart", "relance", "respawn")):
|
||||||
|
intent = "cockpit_restart"
|
||||||
|
cockpit_action = {"type": "restart", "machine": target_machine, "pane": target_role}
|
||||||
|
reply = f"I'll restart {target_machine}:{target_role}."
|
||||||
|
elif any(token in prompt_l for token in ("capture", "log", "logs", "show pane", "see pane")):
|
||||||
|
intent = "cockpit_capture"
|
||||||
|
cockpit_action = {"type": "capture", "machine": target_machine, "pane": target_role}
|
||||||
|
reply = f"I'll capture {target_machine}:{target_role}."
|
||||||
|
elif any(token in prompt_l for token in ("focus", "jump", "go to", "ouvre", "open machine")):
|
||||||
|
intent = "cockpit_focus"
|
||||||
|
cockpit_action = {"type": "focus", "machine": target_machine, "pane": target_role}
|
||||||
|
reply = f"I'll focus {target_machine}:{target_role}."
|
||||||
|
elif any(token in prompt_l for token in ("memory", "remember", "decision", "persona", "what do we know", "qu'est-ce qu", "souviens")):
|
||||||
|
intent = "memory_lookup"
|
||||||
|
reply = "Memory lookup ready."
|
||||||
|
elif explicit_skill is None and prompt.endswith("?") and not any(
|
||||||
|
token in prompt_l for token in ("fix", "build", "implement", "write", "create", "deploy", "restart", "capture", "focus")
|
||||||
|
):
|
||||||
|
intent = "plain_chat"
|
||||||
|
reply = "Here's the operator view."
|
||||||
|
else:
|
||||||
|
intent = "mission_create"
|
||||||
|
routing_overrides = {
|
||||||
|
"skill": matched_skill["id"] if matched_skill else None,
|
||||||
|
"agent": matched_skill["preferred_agent"] if matched_skill else None,
|
||||||
|
"cli": matched_skill["preferred_cli"] if matched_skill else None,
|
||||||
|
}
|
||||||
|
preview = _heuristic_plan(prompt, workspace, "chat-preview", routing_overrides)
|
||||||
|
review = _heuristic_buddy_review(prompt, preview)
|
||||||
|
step = preview["steps"][0]
|
||||||
|
buddy_note = {
|
||||||
|
"answer": review["summary"],
|
||||||
|
"mode": "heuristic",
|
||||||
|
}
|
||||||
|
matched_skill_id = preview["steps"][0]["skill"]
|
||||||
|
reply = (
|
||||||
|
f"I turned that into a mission. Route preview: "
|
||||||
|
f"{step['machine']}/{step['cli']}/{step['backend']} "
|
||||||
|
f"skill={matched_skill_id} agent={step['agent']}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if intent != "mission_create" and any(
|
||||||
|
token in prompt_l for token in ("route", "reroute", "which machine", "which cli", "codex", "claude", "vibe", "copilot")
|
||||||
|
):
|
||||||
|
buddy_note = _heuristic_buddy_answer(prompt, mission)
|
||||||
|
|
||||||
|
if intent == "memory_lookup":
|
||||||
|
if memory_lines:
|
||||||
|
reply = "Memory echoes:\n- " + "\n- ".join(memory_lines[:3])
|
||||||
|
else:
|
||||||
|
reply = "No strong memory hits for that query yet."
|
||||||
|
elif intent == "plain_chat":
|
||||||
|
title = mission["title"] if mission else "global cockpit"
|
||||||
|
route_hint = f" selected={selected_machine or '-'}:{selected_role or '-'}"
|
||||||
|
reply = f"Constant view for {title}.{route_hint}"
|
||||||
|
if memory_lines:
|
||||||
|
reply += "\nMemory echoes:\n- " + "\n- ".join(memory_lines[:2])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"intent": intent,
|
||||||
|
"reply": reply,
|
||||||
|
"message": prompt,
|
||||||
|
"mode": "heuristic",
|
||||||
|
"cockpit_action": cockpit_action,
|
||||||
|
"buddy_note": buddy_note,
|
||||||
|
"memory_hits": memory_hits,
|
||||||
|
"skill_sources": skill_sources,
|
||||||
|
"workspace": workspace,
|
||||||
|
"mission_goal": prompt if intent == "mission_create" else None,
|
||||||
|
"skill": matched_skill,
|
||||||
|
"routing_overrides": {
|
||||||
|
"skill": matched_skill["id"] if matched_skill else None,
|
||||||
|
"agent": matched_skill["preferred_agent"] if matched_skill else None,
|
||||||
|
"cli": matched_skill["preferred_cli"] if matched_skill else None,
|
||||||
|
}
|
||||||
|
if intent == "mission_create" and matched_skill
|
||||||
|
else {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_chat_history(chat_history: list[dict[str, Any]] | None, limit: int = 8) -> list[dict[str, Any]]:
|
||||||
|
if not chat_history:
|
||||||
|
return []
|
||||||
|
important = [
|
||||||
|
entry for entry in chat_history
|
||||||
|
if entry.get("intent") in {"mission_create", "cockpit_error", "buddy_answer", "memory_lookup"}
|
||||||
|
]
|
||||||
|
recent = list(chat_history[-limit:])
|
||||||
|
merged: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for entry in [*important[-3:], *recent]:
|
||||||
|
key = (str(entry.get("timestamp", "")), str(entry.get("content", "")))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
merged.append(entry)
|
||||||
|
return merged[-limit:]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ModelHealth:
|
class ModelHealth:
|
||||||
role: str
|
role: str
|
||||||
@@ -207,7 +443,30 @@ class PlannerEngine:
|
|||||||
self._models = load_models_config()
|
self._models = load_models_config()
|
||||||
self._loaded: dict[str, tuple[Any, Any]] = {}
|
self._loaded: dict[str, tuple[Any, Any]] = {}
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._mlx_python = importlib.util.find_spec("mlx_lm") is not None
|
self._mlx_probe = self._probe_mlx()
|
||||||
|
self._mlx_python = self._mlx_probe["available"]
|
||||||
|
|
||||||
|
def _probe_mlx(self) -> dict[str, Any]:
|
||||||
|
enable_setting = self._models.get("enable_mlx", "auto")
|
||||||
|
requested = str(enable_setting).lower() not in {"0", "false", "off", "no"}
|
||||||
|
package_present = importlib.util.find_spec("mlx_lm") is not None
|
||||||
|
if not requested:
|
||||||
|
return {"requested": False, "package_present": package_present, "available": False, "reason": "disabled"}
|
||||||
|
if not package_present:
|
||||||
|
return {"requested": True, "package_present": False, "available": False, "reason": "mlx_lm not installed"}
|
||||||
|
probe = subprocess.run(
|
||||||
|
[sys.executable, "-c", "import mlx.core as mx; print(mx.default_device())"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"requested": True,
|
||||||
|
"package_present": True,
|
||||||
|
"available": probe.returncode == 0,
|
||||||
|
"reason": probe.stderr.strip() or ("ok" if probe.returncode == 0 else "mlx probe failed"),
|
||||||
|
"stdout": probe.stdout.strip(),
|
||||||
|
"returncode": probe.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
def health(self) -> dict[str, Any]:
|
||||||
health = {}
|
health = {}
|
||||||
@@ -222,8 +481,12 @@ class PlannerEngine:
|
|||||||
).__dict__
|
).__dict__
|
||||||
return {
|
return {
|
||||||
"mlx_python": self._mlx_python,
|
"mlx_python": self._mlx_python,
|
||||||
|
"mlx_probe": self._mlx_probe,
|
||||||
"models": health,
|
"models": health,
|
||||||
"fallback_mode": self._models.get("fallback_mode", "heuristic"),
|
"fallback_mode": self._models.get("fallback_mode", "heuristic"),
|
||||||
|
"agents": list_agents(),
|
||||||
|
"skills": list_skills(),
|
||||||
|
"recommended_skill_stack": recommended_skill_stack(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _load_model(self, role: str) -> tuple[Any, Any]:
|
def _load_model(self, role: str) -> tuple[Any, Any]:
|
||||||
@@ -285,7 +548,8 @@ class PlannerEngine:
|
|||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
def plan_mission(self, mission: dict[str, Any]) -> dict[str, Any]:
|
def plan_mission(self, mission: dict[str, Any]) -> dict[str, Any]:
|
||||||
plan = _heuristic_plan(mission["goal"], mission["workspace"], mission["mission_id"])
|
overrides = mission.get("routing_overrides") or {}
|
||||||
|
plan = _heuristic_plan(mission["goal"], mission["workspace"], mission["mission_id"], overrides)
|
||||||
review = _heuristic_buddy_review(mission["goal"], plan)
|
review = _heuristic_buddy_review(mission["goal"], plan)
|
||||||
fleet = load_fleet_config()
|
fleet = load_fleet_config()
|
||||||
machine_labels = [machine["label"] for machine in fleet["machines"]]
|
machine_labels = [machine["label"] for machine in fleet["machines"]]
|
||||||
@@ -302,9 +566,11 @@ class PlannerEngine:
|
|||||||
"mission_id": mission["mission_id"],
|
"mission_id": mission["mission_id"],
|
||||||
"goal": mission["goal"],
|
"goal": mission["goal"],
|
||||||
"workspace": mission["workspace"],
|
"workspace": mission["workspace"],
|
||||||
|
"routing_overrides": overrides,
|
||||||
"machines": machine_labels,
|
"machines": machine_labels,
|
||||||
"allowed_backends": ["omc", "cli-local", "cli-ssh", "cockpit"],
|
"allowed_backends": ["omc", "cli-local", "cli-ssh", "cockpit"],
|
||||||
"allowed_clis": ["claude", "codex", "vibe"],
|
"allowed_clis": ["claude", "codex", "vibe"],
|
||||||
|
"skill_catalog": skill_catalog_brief(include_internal=True),
|
||||||
},
|
},
|
||||||
indent=2,
|
indent=2,
|
||||||
)
|
)
|
||||||
@@ -317,16 +583,30 @@ class PlannerEngine:
|
|||||||
"steps": [],
|
"steps": [],
|
||||||
}
|
}
|
||||||
for index, step in enumerate(llama_plan["steps"], start=1):
|
for index, step in enumerate(llama_plan["steps"], start=1):
|
||||||
|
resolved = resolve_skill_and_agent(
|
||||||
|
goal=step.get("prompt", mission["goal"]),
|
||||||
|
skill_id=step.get("skill") or overrides.get("skill"),
|
||||||
|
agent_id=step.get("agent") or overrides.get("agent"),
|
||||||
|
cli=step.get("cli") or overrides.get("cli"),
|
||||||
|
)
|
||||||
|
resolved_skill = resolved["skill"]
|
||||||
|
resolved_agent = resolved["agent"]
|
||||||
|
resolved_cli = str(resolved["cli"])
|
||||||
|
resolved_machine = step.get("machine") or overrides.get("machine") or _route_machine(step.get("prompt", mission["goal"]), resolved_skill["id"])
|
||||||
plan["steps"].append(
|
plan["steps"].append(
|
||||||
{
|
{
|
||||||
"step_id": step.get("step_id", f"step-{index}"),
|
"step_id": step.get("step_id", f"step-{index}"),
|
||||||
"kind": step.get("kind", "task"),
|
"kind": step.get("kind", "task"),
|
||||||
"title": step.get("title", f"Step {index}"),
|
"title": step.get("title", f"Step {index}"),
|
||||||
"prompt": step.get("prompt", mission["goal"]),
|
"prompt": step.get("prompt", mission["goal"]),
|
||||||
"machine": step.get("machine", _route_machine(mission["goal"])),
|
"machine": resolved_machine,
|
||||||
"backend": step.get("backend", _route_backend(step.get("machine", local_machine), step.get("cli", "codex"), mission["goal"])),
|
"backend": step.get("backend", _route_backend(resolved_machine, resolved_cli, mission["goal"])),
|
||||||
"cli": step.get("cli", _route_cli(mission["goal"])),
|
"cli": resolved_cli,
|
||||||
"agent": step.get("agent", _route_agent(step.get("cli", "codex"))),
|
"agent": resolved_agent["id"],
|
||||||
|
"agent_role": resolved_agent.get("role"),
|
||||||
|
"skill": resolved_skill["id"],
|
||||||
|
"skill_summary": step.get("skill_summary", resolved_skill["summary"]),
|
||||||
|
"skill_sources": step.get("skill_sources", [item["path"] for item in instruction_skill_sources(mission["workspace"], query=step.get("prompt", mission["goal"]), limit=4)]),
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"attempt": 0,
|
"attempt": 0,
|
||||||
"depends_on": step.get("depends_on", []),
|
"depends_on": step.get("depends_on", []),
|
||||||
@@ -354,7 +634,9 @@ class PlannerEngine:
|
|||||||
if not review.get("agrees", True):
|
if not review.get("agrees", True):
|
||||||
if review.get("suggested_cli") and step["cli"] == "claude":
|
if review.get("suggested_cli") and step["cli"] == "claude":
|
||||||
step["cli"] = review["suggested_cli"]
|
step["cli"] = review["suggested_cli"]
|
||||||
step["agent"] = _route_agent(step["cli"])
|
agent = agent_for_cli(step["cli"])
|
||||||
|
step["agent"] = agent["id"]
|
||||||
|
step["agent_role"] = agent["role"]
|
||||||
if review.get("suggested_backend"):
|
if review.get("suggested_backend"):
|
||||||
step["backend"] = review["suggested_backend"]
|
step["backend"] = review["suggested_backend"]
|
||||||
|
|
||||||
@@ -405,3 +687,53 @@ class PlannerEngine:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def chat(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
mission: dict[str, Any] | None,
|
||||||
|
workspace: str,
|
||||||
|
selected_machine: str | None,
|
||||||
|
selected_role: str | None,
|
||||||
|
chat_history: list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = _heuristic_chat(message, mission, workspace, selected_machine, selected_role)
|
||||||
|
|
||||||
|
if self._mlx_python:
|
||||||
|
system = (
|
||||||
|
"You are Constant, the main cockpit operator for a multi-machine coding fleet. "
|
||||||
|
"Return strict JSON with keys: intent, reply. "
|
||||||
|
"Valid intents: mission_create, cockpit_focus, cockpit_capture, cockpit_restart, cockpit_open, "
|
||||||
|
"buddy_answer, memory_lookup, plain_chat."
|
||||||
|
)
|
||||||
|
user = json.dumps(
|
||||||
|
{
|
||||||
|
"message": message,
|
||||||
|
"workspace": workspace,
|
||||||
|
"mission": mission,
|
||||||
|
"selected_machine": selected_machine,
|
||||||
|
"selected_role": selected_role,
|
||||||
|
"chat_history": _budget_chat_history(chat_history),
|
||||||
|
"memory_hits": result.get("memory_hits", [])[:4],
|
||||||
|
"buddy_note": result.get("buddy_note"),
|
||||||
|
"skills": list_skills(),
|
||||||
|
"skill_catalog": skill_catalog_brief(include_internal=True),
|
||||||
|
"recommended_skill_stack": recommended_skill_stack(),
|
||||||
|
"agents": list_agents(),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model_result = self._call_model_json("planner", system, user)
|
||||||
|
result["intent"] = str(model_result.get("intent", result["intent"]))
|
||||||
|
result["reply"] = str(model_result.get("reply", result["reply"]))
|
||||||
|
result["mode"] = "mlx"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if result["intent"] in {"plain_chat", "memory_lookup"} and result.get("buddy_note") is None:
|
||||||
|
prompt_l = _lower(message)
|
||||||
|
if any(token in prompt_l for token in ("route", "reroute", "machine", "cli", "pane", "codex", "claude", "vibe", "copilot")):
|
||||||
|
result["buddy_note"] = self.buddy_ask(mission, message)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
+69
-2
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
import uuid
|
import uuid
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -10,6 +11,7 @@ from typing import Any
|
|||||||
from . import __version__
|
from . import __version__
|
||||||
from .paths import (
|
from .paths import (
|
||||||
cache_root,
|
cache_root,
|
||||||
|
chat_root,
|
||||||
config_root,
|
config_root,
|
||||||
data_root,
|
data_root,
|
||||||
fleet_config_path,
|
fleet_config_path,
|
||||||
@@ -67,6 +69,7 @@ DEFAULT_FLEET: dict[str, Any] = {
|
|||||||
|
|
||||||
DEFAULT_MODELS: dict[str, Any] = {
|
DEFAULT_MODELS: dict[str, Any] = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
"enable_mlx": "auto",
|
||||||
"planner": {
|
"planner": {
|
||||||
"role": "planner",
|
"role": "planner",
|
||||||
"model_id": "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit",
|
"model_id": "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit",
|
||||||
@@ -109,7 +112,7 @@ def now_utc() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_runtime_dirs() -> None:
|
def ensure_runtime_dirs() -> None:
|
||||||
for path in (cache_root(), config_root(), data_root(), missions_dir(), indexes_dir(), memory_sources_dir()):
|
for path in (cache_root(), chat_root(), config_root(), data_root(), missions_dir(), indexes_dir(), memory_sources_dir()):
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -150,7 +153,14 @@ def load_fleet_config() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def load_models_config() -> dict[str, Any]:
|
def load_models_config() -> dict[str, Any]:
|
||||||
return _read_json_yaml(models_config_path(), DEFAULT_MODELS)
|
payload = _read_json_yaml(models_config_path(), DEFAULT_MODELS)
|
||||||
|
merged = deepcopy(DEFAULT_MODELS)
|
||||||
|
for key, value in payload.items():
|
||||||
|
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||||
|
merged[key].update(value)
|
||||||
|
else:
|
||||||
|
merged[key] = value
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def load_memory_config() -> dict[str, Any]:
|
def load_memory_config() -> dict[str, Any]:
|
||||||
@@ -185,6 +195,63 @@ def mission_artifacts_dir(mission_id: str) -> Path:
|
|||||||
return mission_dir(mission_id) / "artifacts"
|
return mission_dir(mission_id) / "artifacts"
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_chat_slug(workspace: str) -> str:
|
||||||
|
normalized = str(Path(workspace).expanduser().resolve())
|
||||||
|
name = Path(normalized).name or "workspace"
|
||||||
|
digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:10]
|
||||||
|
return f"{name}-{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def chat_file(workspace: str, mission_id: str | None = None) -> Path:
|
||||||
|
ensure_runtime_dirs()
|
||||||
|
if mission_id:
|
||||||
|
return chat_root() / "missions" / f"{mission_id}.ndjson"
|
||||||
|
return chat_root() / "workspaces" / f"{_workspace_chat_slug(workspace)}.ndjson"
|
||||||
|
|
||||||
|
|
||||||
|
def read_chat_history(workspace: str, mission_id: str | None = None, limit: int = 80) -> list[dict[str, Any]]:
|
||||||
|
path = chat_file(workspace, mission_id=mission_id)
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines()[-limit:]:
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(raw))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def append_chat_message(
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
workspace: str,
|
||||||
|
mission_id: str | None = None,
|
||||||
|
intent: str | None = None,
|
||||||
|
machine: str | None = None,
|
||||||
|
pane: str | None = None,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
path = chat_file(workspace, mission_id=mission_id)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
entry = {
|
||||||
|
"timestamp": now_utc(),
|
||||||
|
"role": role,
|
||||||
|
"content": content,
|
||||||
|
"intent": intent or "plain_chat",
|
||||||
|
"workspace": str(Path(workspace).expanduser().resolve()),
|
||||||
|
"mission_id": mission_id,
|
||||||
|
"machine": machine,
|
||||||
|
"pane": pane,
|
||||||
|
"meta": meta or {},
|
||||||
|
}
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def create_mission(goal: str, workspace: str, routing_overrides: dict[str, Any] | None = None) -> dict[str, Any]:
|
def create_mission(goal: str, workspace: str, routing_overrides: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||||
mission_id = uuid.uuid4().hex[:12]
|
mission_id = uuid.uuid4().hex[:12]
|
||||||
mission = {
|
mission = {
|
||||||
|
|||||||
+1301
-203
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
version = 1
|
||||||
|
local_machine = "command-center"
|
||||||
|
repo_dir = "$HOME/constant"
|
||||||
|
|
||||||
|
[[machines]]
|
||||||
|
label = "command-center"
|
||||||
|
target = "local"
|
||||||
|
auto_clis = ["codex", "vibe", "claude"]
|
||||||
|
manual_clis = ["copilot"]
|
||||||
|
backends = ["omc", "cli-local", "cockpit"]
|
||||||
|
|
||||||
|
[[machines]]
|
||||||
|
label = "builder-a"
|
||||||
|
target = "dev@builder-a"
|
||||||
|
auto_clis = ["codex", "vibe", "claude"]
|
||||||
|
manual_clis = ["copilot"]
|
||||||
|
backends = ["cli-ssh", "cockpit"]
|
||||||
|
|
||||||
|
[[machines]]
|
||||||
|
label = "builder-b"
|
||||||
|
target = "dev@builder-b"
|
||||||
|
auto_clis = ["codex", "vibe", "claude"]
|
||||||
|
manual_clis = ["copilot"]
|
||||||
|
backends = ["cli-ssh", "cockpit"]
|
||||||
|
|
||||||
|
[[machines]]
|
||||||
|
label = "edge-a"
|
||||||
|
target = "dev@edge-a"
|
||||||
|
auto_clis = ["codex", "vibe", "claude"]
|
||||||
|
manual_clis = ["copilot"]
|
||||||
|
backends = ["cli-ssh", "cockpit"]
|
||||||
|
|
||||||
|
[[machines]]
|
||||||
|
label = "lab-a"
|
||||||
|
target = "dev@lab-a"
|
||||||
|
auto_clis = ["codex", "vibe", "claude"]
|
||||||
|
manual_clis = ["copilot"]
|
||||||
|
backends = ["cli-ssh", "cockpit"]
|
||||||
@@ -10,6 +10,135 @@ done
|
|||||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||||
repo_dir="$(cd "$script_dir/.." && pwd -P)"
|
repo_dir="$(cd "$script_dir/.." && pwd -P)"
|
||||||
default_python="$HOME/.local/share/constant/venv/bin/python3"
|
default_python="$HOME/.local/share/constant/venv/bin/python3"
|
||||||
|
rust_bin="$repo_dir/target/debug/constant"
|
||||||
|
cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/constant/wrapper"
|
||||||
|
probe_timeout_sec="${CONSTANT_RUST_PROBE_TIMEOUT_SEC:-2}"
|
||||||
|
probe_fail_ttl_sec="${CONSTANT_RUST_PROBE_FAIL_TTL_SEC:-300}"
|
||||||
|
force_rust_recheck="${CONSTANT_RUST_RECHECK:-0}"
|
||||||
|
|
||||||
|
ancestor_comm_contains() {
|
||||||
|
local needle="$1"
|
||||||
|
local pid="${PPID:-}"
|
||||||
|
local depth=0
|
||||||
|
while [[ -n "$pid" && "$pid" =~ ^[0-9]+$ && "$pid" -gt 1 && $depth -lt 12 ]]; do
|
||||||
|
local comm=""
|
||||||
|
comm="$(ps -o comm= -p "$pid" 2>/dev/null | tr -d '\n')" || break
|
||||||
|
[[ -z "$comm" ]] && break
|
||||||
|
if [[ "$comm" == *"$needle"* ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
pid="$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d '[:space:]')" || break
|
||||||
|
(( depth += 1 ))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stat_signature() {
|
||||||
|
local target="$1"
|
||||||
|
/usr/bin/stat -f '%m:%z' "$target" 2>/dev/null || printf 'missing\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
record_wrapper_mode() {
|
||||||
|
local mode="$1"
|
||||||
|
local reason="$2"
|
||||||
|
mkdir -p "$cache_root"
|
||||||
|
printf '%s %s %s\n' "$mode" "$reason" "$(date +%s)" > "$cache_root/last-mode"
|
||||||
|
export CONSTANT_WRAPPER_LAST_REASON="$reason"
|
||||||
|
}
|
||||||
|
|
||||||
|
cache_matches() {
|
||||||
|
local cache_file="$1"
|
||||||
|
local expected_signature="$2"
|
||||||
|
[[ -f "$cache_file" ]] || return 1
|
||||||
|
local cached_signature=""
|
||||||
|
local cached_epoch=""
|
||||||
|
IFS=' ' read -r cached_signature cached_epoch < "$cache_file" || return 1
|
||||||
|
[[ "$cached_signature" == "$expected_signature" ]] || return 1
|
||||||
|
printf '%s\n' "${cached_epoch:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_rust_probe() {
|
||||||
|
local probe_target="$1"
|
||||||
|
local timeout_sec="$2"
|
||||||
|
local probe_log="$cache_root/rust-probe.log"
|
||||||
|
mkdir -p "$cache_root"
|
||||||
|
|
||||||
|
("$probe_target" --version >"$probe_log" 2>&1) &
|
||||||
|
local probe_pid=$!
|
||||||
|
local waited=0
|
||||||
|
local deadline=$(( timeout_sec * 10 ))
|
||||||
|
|
||||||
|
while (( waited < deadline )); do
|
||||||
|
if ! kill -0 "$probe_pid" 2>/dev/null; then
|
||||||
|
local status=0
|
||||||
|
wait "$probe_pid" || status=$?
|
||||||
|
[[ $status -eq 0 ]]
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
(( waited += 1 ))
|
||||||
|
done
|
||||||
|
|
||||||
|
kill "$probe_pid" 2>/dev/null || true
|
||||||
|
wait "$probe_pid" 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
warn_rust_fallback() {
|
||||||
|
[[ -t 2 ]] || return 0
|
||||||
|
printf '%s\n' "constant: Rust startup probe failed in this macOS session, falling back to Python." >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
warn_codex_rust_bypass() {
|
||||||
|
[[ -t 2 ]] || return 0
|
||||||
|
printf '%s\n' "constant: running under Codex runner, skipping Rust startup path and using Python fallback." >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${CONSTANT_USE_RUST:-0}" == "1" && "${CONSTANT_USE_PYTHON:-0}" != "1" ]]; then
|
||||||
|
cargo build --manifest-path "$repo_dir/Cargo.toml" --quiet
|
||||||
|
record_wrapper_mode "rust" "forced-rust"
|
||||||
|
export CONSTANT_REPO_DIR="$repo_dir"
|
||||||
|
export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}"
|
||||||
|
exec "$rust_bin" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${CONSTANT_USE_PYTHON:-0}" != "1" ]]; then
|
||||||
|
if ancestor_comm_contains "codex-aarch64-apple-darwin"; then
|
||||||
|
record_wrapper_mode "python" "codex-runner"
|
||||||
|
warn_codex_rust_bypass
|
||||||
|
else
|
||||||
|
cargo build --manifest-path "$repo_dir/Cargo.toml" --quiet
|
||||||
|
rust_signature="$(stat_signature "$rust_bin")"
|
||||||
|
mkdir -p "$cache_root"
|
||||||
|
|
||||||
|
ok_epoch="$(cache_matches "$cache_root/rust-ok" "$rust_signature" || true)"
|
||||||
|
if [[ "$force_rust_recheck" != "1" && -n "$ok_epoch" ]]; then
|
||||||
|
record_wrapper_mode "rust" "cached-rust-ok"
|
||||||
|
export CONSTANT_REPO_DIR="$repo_dir"
|
||||||
|
export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}"
|
||||||
|
exec "$rust_bin" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail_epoch="$(cache_matches "$cache_root/rust-fail" "$rust_signature" || true)"
|
||||||
|
now_epoch="$(date +%s)"
|
||||||
|
if [[ "$force_rust_recheck" != "1" && -n "$fail_epoch" ]] && (( now_epoch - fail_epoch < probe_fail_ttl_sec )); then
|
||||||
|
record_wrapper_mode "python" "cached-rust-fail"
|
||||||
|
:
|
||||||
|
elif run_rust_probe "$rust_bin" "$probe_timeout_sec"; then
|
||||||
|
printf '%s %s\n' "$rust_signature" "$now_epoch" > "$cache_root/rust-ok"
|
||||||
|
rm -f "$cache_root/rust-fail"
|
||||||
|
record_wrapper_mode "rust" "probe-ok"
|
||||||
|
export CONSTANT_REPO_DIR="$repo_dir"
|
||||||
|
export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}"
|
||||||
|
exec "$rust_bin" "$@"
|
||||||
|
else
|
||||||
|
printf '%s %s\n' "$rust_signature" "$now_epoch" > "$cache_root/rust-fail"
|
||||||
|
rm -f "$cache_root/rust-ok"
|
||||||
|
record_wrapper_mode "python" "probe-failed"
|
||||||
|
warn_rust_fallback
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
python_bin="${CONSTANT_PYTHON:-}"
|
python_bin="${CONSTANT_PYTHON:-}"
|
||||||
if [[ -z "$python_bin" ]]; then
|
if [[ -z "$python_bin" ]]; then
|
||||||
@@ -21,4 +150,8 @@ if [[ -z "$python_bin" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
export PYTHONPATH="$repo_dir${PYTHONPATH+:$PYTHONPATH}"
|
export PYTHONPATH="$repo_dir${PYTHONPATH+:$PYTHONPATH}"
|
||||||
|
export CONSTANT_PROG_NAME="${CONSTANT_PROG_NAME:-$(basename "$0")}"
|
||||||
|
if [[ "${CONSTANT_USE_PYTHON:-0}" == "1" ]]; then
|
||||||
|
record_wrapper_mode "python" "forced-python"
|
||||||
|
fi
|
||||||
exec "$python_bin" -m constant "$@"
|
exec "$python_bin" -m constant "$@"
|
||||||
|
|||||||
Executable → Regular
+32
-2
@@ -8,6 +8,36 @@ while [[ -L "$script_source" ]]; do
|
|||||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||||
done
|
done
|
||||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||||
|
bootstrap_file="${ZELLIJ_AI_BOOTSTRAP_FILE:-}"
|
||||||
|
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
||||||
|
|
||||||
export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
|
if [[ -n "$bootstrap_file" ]]; then
|
||||||
exec "$script_dir/zellij-ai-bootstrap.sh" "$@"
|
mkdir -p "$(dirname "$bootstrap_file")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$bootstrap_file" && ! -f "$bootstrap_file" ]]; then
|
||||||
|
: >"$bootstrap_file"
|
||||||
|
|
||||||
|
printf -v codex_cmd '%q' "$script_dir/constant-codex-pane.sh"
|
||||||
|
printf -v copilot_cmd '%q' "$script_dir/constant-copilot-pane.sh"
|
||||||
|
printf -v vibe_cmd '%q' "$script_dir/constant-vibe-pane.sh"
|
||||||
|
|
||||||
|
zellij action new-pane -d right -n "${ZELLIJ_AI_CODEX_LABEL:-codex}" --cwd "$workspace"
|
||||||
|
zellij action move-focus right
|
||||||
|
zellij action write-chars "$codex_cmd"
|
||||||
|
zellij action write 10
|
||||||
|
|
||||||
|
zellij action new-pane -d down -n "${ZELLIJ_AI_COPILOT_LABEL:-copilot}" --cwd "$workspace"
|
||||||
|
zellij action move-focus down
|
||||||
|
zellij action write-chars "$copilot_cmd"
|
||||||
|
zellij action write 10
|
||||||
|
|
||||||
|
zellij action new-pane -d down -n "${ZELLIJ_AI_VIBE_LABEL:-vibe}" --cwd "$workspace"
|
||||||
|
zellij action move-focus down
|
||||||
|
zellij action write-chars "$vibe_cmd"
|
||||||
|
zellij action write 10
|
||||||
|
|
||||||
|
zellij action move-focus left
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$script_dir/constant-claude-pane.sh"
|
||||||
|
|||||||
Executable → Regular
+54
-2
@@ -8,6 +8,58 @@ while [[ -L "$script_source" ]]; do
|
|||||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||||
done
|
done
|
||||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||||
|
source "$script_dir/constant-common.sh"
|
||||||
|
|
||||||
export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
|
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
||||||
exec "$script_dir/zellij-ai-copilot-pane.sh" "$@"
|
machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}"
|
||||||
|
session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
|
||||||
|
repo_dir="${ZELLIJ_AI_REPO_DIR:-$(cd "$script_dir/.." && pwd -P)}"
|
||||||
|
bus_dir="${ZELLIJ_AI_BUS_DIR:-$(zellij_ai_session_state_dir "$session_name")/bus}"
|
||||||
|
label="${ZELLIJ_AI_COPILOT_LABEL:-copilot}"
|
||||||
|
|
||||||
|
if [[ ! -d "$workspace" ]]; then
|
||||||
|
echo "Workspace not found: $workspace" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$(zellij_ai_agent_path)"
|
||||||
|
|
||||||
|
if [[ -n "$repo_dir" && -d "$repo_dir/scripts" ]]; then
|
||||||
|
export PATH="$repo_dir/scripts:$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v copilot >/dev/null 2>&1; then
|
||||||
|
echo "GitHub Copilot CLI not found on the host." >&2
|
||||||
|
echo "Install it with: npm install -g @github/copilot" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$bus_dir/messages"
|
||||||
|
|
||||||
|
export ZELLIJ_AI_ROLE="$label"
|
||||||
|
export ZELLIJ_AI_MACHINE_NAME="$machine_name"
|
||||||
|
export ZELLIJ_AI_SESSION="$session_name"
|
||||||
|
export ZELLIJ_AI_BUS_DIR="$bus_dir"
|
||||||
|
|
||||||
|
cd "$workspace"
|
||||||
|
|
||||||
|
echo "Copilot pane"
|
||||||
|
echo "machine: $machine_name"
|
||||||
|
echo "workspace: $workspace"
|
||||||
|
echo "bus: $bus_dir"
|
||||||
|
if [[ -n "$repo_dir" ]]; then
|
||||||
|
echo "helper: $repo_dir/scripts/ai-msg.sh"
|
||||||
|
fi
|
||||||
|
echo "login: start copilot and use /login if needed"
|
||||||
|
echo
|
||||||
|
|
||||||
|
copilot
|
||||||
|
status=$?
|
||||||
|
|
||||||
|
if [[ $status -ne 0 ]]; then
|
||||||
|
echo
|
||||||
|
echo "Copilot exited with status $status."
|
||||||
|
echo "If macOS reports 'SecItemCopyMatching failed -50', open copilot manually once and re-run /login."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
|
|||||||
Executable → Regular
+38
-9
@@ -1,13 +1,42 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
script_source="${BASH_SOURCE[0]:-$0}"
|
tabs_file="${ZELLIJ_AI_FLEET_TABS_FILE:-}"
|
||||||
while [[ -L "$script_source" ]]; do
|
tabs_created_file="${ZELLIJ_AI_FLEET_CREATED_FILE:-}"
|
||||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
current_label="${ZELLIJ_AI_FLEET_CURRENT_LABEL:-}"
|
||||||
script_source="$(readlink "$script_source")"
|
current_wrapper="${ZELLIJ_AI_FLEET_CURRENT_WRAPPER:-}"
|
||||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
|
||||||
done
|
|
||||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
|
||||||
|
|
||||||
export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
|
if [[ -z "$current_wrapper" || ! -x "$current_wrapper" ]]; then
|
||||||
exec "$script_dir/zellij-ai-fleet-bootstrap.sh" "$@"
|
echo "Fleet bootstrap wrapper not found: $current_wrapper" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$current_label" ]]; then
|
||||||
|
zellij action rename-tab "$current_label"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$tabs_created_file" ]]; then
|
||||||
|
mkdir -p "$(dirname "$tabs_created_file")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$tabs_file" && -f "$tabs_file" && -n "$tabs_created_file" && ! -f "$tabs_created_file" ]]; then
|
||||||
|
: >"$tabs_created_file"
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r tab_label layout_file tab_cwd; do
|
||||||
|
if [[ -z "$tab_label" || -z "$layout_file" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$tab_cwd" ]]; then
|
||||||
|
zellij action new-tab -n "$tab_label" -l "$layout_file" --cwd "$tab_cwd"
|
||||||
|
else
|
||||||
|
zellij action new-tab -n "$tab_label" -l "$layout_file"
|
||||||
|
fi
|
||||||
|
done <"$tabs_file"
|
||||||
|
|
||||||
|
if [[ -n "$current_label" ]]; then
|
||||||
|
zellij action go-to-tab-name "$current_label"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$current_wrapper"
|
||||||
|
|||||||
+107
-31
@@ -45,21 +45,23 @@ require_arg() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
build_constant_window_command() {
|
build_constant_window_command() {
|
||||||
local inner
|
local cmd=(
|
||||||
printf -v inner 'cd %q && export PATH=%q; exec %q tui --workspace %q --local-session %q --session %q' \
|
env
|
||||||
"$workspace" \
|
"PATH=$(zellij_ai_agent_path)"
|
||||||
"$(zellij_ai_agent_path)" \
|
"CONSTANT_USE_PYTHON=1"
|
||||||
"$repo_dir/scripts/Constant" \
|
"$repo_dir/scripts/Constant"
|
||||||
"$workspace" \
|
tui
|
||||||
"$local_session" \
|
--workspace "$workspace"
|
||||||
"$remote_session"
|
--local-session "$local_session"
|
||||||
printf 'bash -lc %q' "$inner"
|
--session "$remote_session"
|
||||||
|
)
|
||||||
|
printf '%q ' "${cmd[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
build_remote_window_command() {
|
build_remote_window_command() {
|
||||||
local label="$1"
|
local label="$1"
|
||||||
local target="$2"
|
local target="$2"
|
||||||
local inner remote_script cmd cmd_joined
|
local remote_script cmd
|
||||||
|
|
||||||
remote_script="$repo_dir/scripts/constant-tmux-remote-window.sh"
|
remote_script="$repo_dir/scripts/constant-tmux-remote-window.sh"
|
||||||
cmd=(
|
cmd=(
|
||||||
@@ -77,12 +79,7 @@ build_remote_window_command() {
|
|||||||
cmd+=(--recreate)
|
cmd+=(--recreate)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf -v cmd_joined '%q ' "${cmd[@]}"
|
printf '%q ' "${cmd[@]}"
|
||||||
printf -v inner 'cd %q && export PATH=%q; exec %s' \
|
|
||||||
"$repo_dir" \
|
|
||||||
"$(zellij_ai_agent_path)" \
|
|
||||||
"$cmd_joined"
|
|
||||||
printf 'bash -lc %q' "$inner"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
link_local_window() {
|
link_local_window() {
|
||||||
@@ -111,6 +108,91 @@ link_local_window() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configure_fleet_session() {
|
||||||
|
local session_name="$1"
|
||||||
|
tmux rename-window -t "${session_name}:0" Constant >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g set-clipboard on >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g remain-on-exit on >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g mouse on >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g status on >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g status-position top >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g status-justify left >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g status-left-length 24 >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g status-right-length 48 >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g status-left '#[fg=black,bg=green,bold] Constant #[default] ' >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g status-right '#[fg=colour250]#S #[fg=colour244]| #[fg=colour252]%H:%M #[fg=colour244]%d-%b' >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g status-style 'bg=colour234,fg=colour252' >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g window-status-separator ' ' >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g window-status-format '#[fg=colour245,bg=colour236] #I:#W ' >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g window-status-current-format '#[fg=colour16,bg=colour46,bold] #I:#W ' >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g window-status-current-style 'fg=colour16,bg=colour46,bold' >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g allow-rename off >/dev/null 2>&1 || true
|
||||||
|
tmux set-option -t "$session_name" -g automatic-rename off >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
attach_fleet_session() {
|
||||||
|
local session_name="$1"
|
||||||
|
tmux select-window -t "${session_name}:Constant" >/dev/null 2>&1 || true
|
||||||
|
if [[ -n "${TMUX:-}" ]]; then
|
||||||
|
exec tmux switch-client -t "$session_name"
|
||||||
|
fi
|
||||||
|
exec tmux attach-session -t "$session_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
window_pane_dead() {
|
||||||
|
local session_name="$1"
|
||||||
|
local window_name="$2"
|
||||||
|
local dead
|
||||||
|
dead="$(tmux display-message -p -t "${session_name}:${window_name}" '#{pane_dead}' 2>/dev/null || true)"
|
||||||
|
[[ "$dead" == "1" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_constant_window() {
|
||||||
|
local command_string
|
||||||
|
command_string="$(build_constant_window_command)"
|
||||||
|
if constant_tmux_window_exists "$local_session" "Constant"; then
|
||||||
|
if window_pane_dead "$local_session" "Constant"; then
|
||||||
|
tmux respawn-pane -k -t "${local_session}:Constant" "$command_string"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
tmux new-window -d -t "${local_session}:" -n Constant "$command_string"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_remote_window() {
|
||||||
|
local label="$1"
|
||||||
|
local target="$2"
|
||||||
|
local command_string
|
||||||
|
command_string="$(build_remote_window_command "$label" "$target")"
|
||||||
|
if constant_tmux_window_exists "$local_session" "$label"; then
|
||||||
|
if window_pane_dead "$local_session" "$label"; then
|
||||||
|
tmux respawn-pane -k -t "${local_session}:${label}" "$command_string"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
tmux new-window -d -t "${local_session}:" -n "$label" "$command_string"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_fleet_windows() {
|
||||||
|
local machine_spec parsed_spec label target
|
||||||
|
|
||||||
|
ensure_constant_window
|
||||||
|
for machine_spec in "${machines[@]}"; do
|
||||||
|
parsed_spec="$(zellij_ai_parse_machine_spec "$machine_spec")"
|
||||||
|
IFS=$'\t' read -r label target <<<"$parsed_spec"
|
||||||
|
|
||||||
|
if [[ "$label" == "Constant" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if zellij_ai_is_local_target "$target"; then
|
||||||
|
link_local_window "$label"
|
||||||
|
else
|
||||||
|
ensure_remote_window "$label" "$target"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
constant_tmux_require
|
constant_tmux_require
|
||||||
zellij_ai_require_command ssh
|
zellij_ai_require_command ssh
|
||||||
|
|
||||||
@@ -190,10 +272,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if $attach_only; then
|
if $attach_only; then
|
||||||
if [[ -n "${TMUX:-}" ]]; then
|
attach_fleet_session "$local_session"
|
||||||
exec tmux switch-client -t "$local_session"
|
|
||||||
fi
|
|
||||||
exec tmux attach-session -t "$local_session"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$workspace" ]]; then
|
if [[ -z "$workspace" ]]; then
|
||||||
@@ -214,17 +293,18 @@ if $recreate && constant_tmux_session_exists "$local_session"; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if constant_tmux_session_exists "$local_session"; then
|
if constant_tmux_session_exists "$local_session"; then
|
||||||
if [[ -n "${TMUX:-}" ]]; then
|
configure_fleet_session "$local_session"
|
||||||
exec tmux switch-client -t "$local_session"
|
ensure_fleet_windows
|
||||||
|
tmux select-window -t "${local_session}:Constant" >/dev/null 2>&1 || true
|
||||||
|
if $ensure_only; then
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
exec tmux attach-session -t "$local_session"
|
attach_fleet_session "$local_session"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$(zellij_ai_fleet_state_dir "$local_session")"
|
mkdir -p "$(zellij_ai_fleet_state_dir "$local_session")"
|
||||||
tmux new-session -d -s "$local_session" -n Constant "$(build_constant_window_command)"
|
tmux new-session -d -s "$local_session" -n Constant "$(build_constant_window_command)"
|
||||||
tmux set-option -t "$local_session" -g set-clipboard on >/dev/null 2>&1 || true
|
configure_fleet_session "$local_session"
|
||||||
tmux set-option -t "$local_session" -g remain-on-exit on >/dev/null 2>&1 || true
|
|
||||||
tmux set-option -t "$local_session" -g mouse on >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
for machine_spec in "${machines[@]}"; do
|
for machine_spec in "${machines[@]}"; do
|
||||||
parsed_spec="$(zellij_ai_parse_machine_spec "$machine_spec")"
|
parsed_spec="$(zellij_ai_parse_machine_spec "$machine_spec")"
|
||||||
@@ -247,8 +327,4 @@ if $ensure_only; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${TMUX:-}" ]]; then
|
attach_fleet_session "$local_session"
|
||||||
exec tmux switch-client -t "$local_session"
|
|
||||||
else
|
|
||||||
exec tmux attach-session -t "$local_session"
|
|
||||||
fi
|
|
||||||
|
|||||||
Executable → Regular
+44
-2
@@ -8,6 +8,48 @@ while [[ -L "$script_source" ]]; do
|
|||||||
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
|
||||||
done
|
done
|
||||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||||
|
source "$script_dir/constant-common.sh"
|
||||||
|
|
||||||
export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
|
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
||||||
exec "$script_dir/zellij-ai-vibe-pane.sh" "$@"
|
machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}"
|
||||||
|
session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
|
||||||
|
repo_dir="${ZELLIJ_AI_REPO_DIR:-$(cd "$script_dir/.." && pwd -P)}"
|
||||||
|
bus_dir="${ZELLIJ_AI_BUS_DIR:-$(zellij_ai_session_state_dir "$session_name")/bus}"
|
||||||
|
label="${ZELLIJ_AI_VIBE_LABEL:-vibe}"
|
||||||
|
|
||||||
|
if [[ ! -d "$workspace" ]]; then
|
||||||
|
echo "Workspace not found: $workspace" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$(zellij_ai_agent_path)"
|
||||||
|
|
||||||
|
if [[ -n "$repo_dir" && -d "$repo_dir/scripts" ]]; then
|
||||||
|
export PATH="$repo_dir/scripts:$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v vibe >/dev/null 2>&1; then
|
||||||
|
echo "Mistral Vibe CLI not found on the host." >&2
|
||||||
|
echo "Install it with: uv tool install mistral-vibe" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$bus_dir/messages"
|
||||||
|
|
||||||
|
export ZELLIJ_AI_ROLE="$label"
|
||||||
|
export ZELLIJ_AI_MACHINE_NAME="$machine_name"
|
||||||
|
export ZELLIJ_AI_SESSION="$session_name"
|
||||||
|
export ZELLIJ_AI_BUS_DIR="$bus_dir"
|
||||||
|
|
||||||
|
cd "$workspace"
|
||||||
|
|
||||||
|
echo "Vibe pane"
|
||||||
|
echo "machine: $machine_name"
|
||||||
|
echo "workspace: $workspace"
|
||||||
|
echo "bus: $bus_dir"
|
||||||
|
if [[ -n "$repo_dir" ]]; then
|
||||||
|
echo "helper: $repo_dir/scripts/ai-msg.sh"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
exec vibe
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ done
|
|||||||
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
|
||||||
source "$script_dir/constant-common.sh"
|
source "$script_dir/constant-common.sh"
|
||||||
|
|
||||||
|
shell_fallback() {
|
||||||
|
local reason="${1:-Claude pane ended.}"
|
||||||
|
local shell_bin="${SHELL:-/bin/bash}"
|
||||||
|
echo
|
||||||
|
echo "$reason"
|
||||||
|
echo "Keeping the pane alive in an interactive shell."
|
||||||
|
echo "Workspace: $workspace"
|
||||||
|
echo
|
||||||
|
exec "$shell_bin" -il
|
||||||
|
}
|
||||||
|
|
||||||
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
|
||||||
machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}"
|
machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}"
|
||||||
session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
|
session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
|
||||||
@@ -50,4 +61,11 @@ if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then
|
|||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
|
|
||||||
exec claude
|
if ! command -v claude >/dev/null 2>&1; then
|
||||||
|
shell_fallback "Claude CLI not found on the host."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! claude; then
|
||||||
|
rc=$?
|
||||||
|
shell_fallback "Claude exited with status ${rc}."
|
||||||
|
fi
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ slot:
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shell_fallback() {
|
||||||
|
local reason="${1:-Codex pane ended.}"
|
||||||
|
local shell_bin="${SHELL:-/bin/bash}"
|
||||||
|
echo
|
||||||
|
echo "$reason"
|
||||||
|
echo "Keeping the pane alive in an interactive shell."
|
||||||
|
echo "Workspace: $workspace"
|
||||||
|
echo "CODEX_HOME: $CODEX_HOME"
|
||||||
|
echo "You can retry manually with: codex login --device-auth"
|
||||||
|
echo
|
||||||
|
exec "$shell_bin" -il
|
||||||
|
}
|
||||||
|
|
||||||
warn_deprecated() {
|
warn_deprecated() {
|
||||||
printf 'Warning: %s\n' "$*" >&2
|
printf 'Warning: %s\n' "$*" >&2
|
||||||
}
|
}
|
||||||
@@ -105,8 +118,18 @@ if [[ ! -f "$CODEX_HOME/auth.json" ]]; then
|
|||||||
echo "No auth.json found for $label."
|
echo "No auth.json found for $label."
|
||||||
echo "Starting: codex login --device-auth"
|
echo "Starting: codex login --device-auth"
|
||||||
echo
|
echo
|
||||||
codex login --device-auth
|
if ! codex login --device-auth; then
|
||||||
|
echo
|
||||||
|
echo "Codex login did not complete successfully."
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec codex --no-alt-screen -C "$workspace"
|
if [[ ! -f "$CODEX_HOME/auth.json" ]]; then
|
||||||
|
shell_fallback "Codex auth is still missing."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! codex --no-alt-screen -C "$workspace"; then
|
||||||
|
rc=$?
|
||||||
|
shell_fallback "Codex exited with status ${rc}."
|
||||||
|
fi
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ zellij_ai_fleet_config_candidates() {
|
|||||||
if [[ -n "${CONSTANT_FLEET_CONFIG:-}" ]]; then
|
if [[ -n "${CONSTANT_FLEET_CONFIG:-}" ]]; then
|
||||||
printf '%s\n' "$CONSTANT_FLEET_CONFIG"
|
printf '%s\n' "$CONSTANT_FLEET_CONFIG"
|
||||||
fi
|
fi
|
||||||
|
printf '%s\n' "$(zellij_ai_config_root)/fleet.toml"
|
||||||
printf '%s\n' "$(zellij_ai_config_root)/fleet.json"
|
printf '%s\n' "$(zellij_ai_config_root)/fleet.json"
|
||||||
printf '%s\n' "$(zellij_ai_config_root)/fleet.yaml"
|
printf '%s\n' "$(zellij_ai_config_root)/fleet.yaml"
|
||||||
}
|
}
|
||||||
@@ -32,16 +33,26 @@ zellij_ai_fleet_config_query() {
|
|||||||
[[ -n "$config_path" ]] || return 1
|
[[ -n "$config_path" ]] || return 1
|
||||||
command -v python3 >/dev/null 2>&1 || return 1
|
command -v python3 >/dev/null 2>&1 || return 1
|
||||||
|
|
||||||
python3 - "$config_path" "$expr" <<'PY'
|
python3 - "$config_path" "$expr" <<'PY'
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib
|
||||||
|
except ModuleNotFoundError: # pragma: no cover
|
||||||
|
tomllib = None
|
||||||
|
|
||||||
path = Path(sys.argv[1])
|
path = Path(sys.argv[1])
|
||||||
expr = sys.argv[2]
|
expr = sys.argv[2]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
if path.suffix == ".toml":
|
||||||
|
if tomllib is None:
|
||||||
|
raise SystemExit(1)
|
||||||
|
payload = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except Exception:
|
except Exception:
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,18 @@ ensure_uv_tool() {
|
|||||||
ensure_command "$binary_name" || fail "Failed to install ${package_name} with uv."
|
ensure_command "$binary_name" || fail "Failed to install ${package_name} with uv."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_constant_wrappers() {
|
||||||
|
local bin_dir="$HOME/.local/bin"
|
||||||
|
mkdir -p "$bin_dir"
|
||||||
|
|
||||||
|
ln -snf "$repo_dir/scripts/Constant" "$bin_dir/Constant"
|
||||||
|
ln -snf "$repo_dir/scripts/Constant" "$bin_dir/constant"
|
||||||
|
ln -snf "$repo_dir/scripts/constant-fleet.sh" "$bin_dir/constant-fleet"
|
||||||
|
ln -snf "$repo_dir/scripts/constant-machine.sh" "$bin_dir/constant-machine"
|
||||||
|
ln -snf "$repo_dir/scripts/constant-fleet-install.sh" "$bin_dir/constant-fleet-install"
|
||||||
|
ln -snf "$repo_dir/scripts/constant-machine-install.sh" "$bin_dir/constant-machine-install"
|
||||||
|
}
|
||||||
|
|
||||||
os_name="$(uname -s)"
|
os_name="$(uname -s)"
|
||||||
linux_id=""
|
linux_id=""
|
||||||
repo_dir="$(zellij_ai_expand_home_path "$(zellij_ai_default_repo_dir)")"
|
repo_dir="$(zellij_ai_expand_home_path "$(zellij_ai_default_repo_dir)")"
|
||||||
@@ -273,7 +285,7 @@ run_check() {
|
|||||||
print_status repo_dir "$repo_dir"
|
print_status repo_dir "$repo_dir"
|
||||||
print_status codex_home "$codex_home"
|
print_status codex_home "$codex_home"
|
||||||
|
|
||||||
for binary in bash git tmux node npm uv claude codex copilot vibe; do
|
for binary in bash git tmux node npm uv claude codex copilot vibe constant; do
|
||||||
if command -v "$binary" >/dev/null 2>&1; then
|
if command -v "$binary" >/dev/null 2>&1; then
|
||||||
print_status "$binary" "$(command -v "$binary")"
|
print_status "$binary" "$(command -v "$binary")"
|
||||||
else
|
else
|
||||||
@@ -394,6 +406,8 @@ run_install() {
|
|||||||
ensure_npm_global "@openai/codex" codex
|
ensure_npm_global "@openai/codex" codex
|
||||||
ensure_npm_global "@github/copilot" copilot
|
ensure_npm_global "@github/copilot" copilot
|
||||||
ensure_uv_tool "mistral-vibe" vibe
|
ensure_uv_tool "mistral-vibe" vibe
|
||||||
|
ensure_constant_wrappers
|
||||||
|
refresh_path
|
||||||
|
|
||||||
run_check
|
run_check
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::capabilities::match_skill;
|
||||||
|
use crate::state::Mission;
|
||||||
|
|
||||||
|
pub fn buddy_ask(
|
||||||
|
mission: Option<&Mission>,
|
||||||
|
prompt: &str,
|
||||||
|
_workspace: Option<&str>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let skill = match_skill(prompt, true);
|
||||||
|
let title = mission
|
||||||
|
.map(|entry| entry.title.as_str())
|
||||||
|
.unwrap_or("global cockpit");
|
||||||
|
let route_note = if prompt.to_lowercase().contains("route")
|
||||||
|
|| prompt.to_lowercase().contains("machine")
|
||||||
|
|| prompt.to_lowercase().contains("cli")
|
||||||
|
{
|
||||||
|
format!(
|
||||||
|
"Treat this as a routing discussion first, with skill={} as the default lens.",
|
||||||
|
skill.id
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Treat this as a {} discussion before execution.",
|
||||||
|
skill.summary
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let answer = format!("Qwen buddy heuristic view for {title}: {route_note}");
|
||||||
|
Ok(json!({
|
||||||
|
"answer": answer,
|
||||||
|
"mode": "rust-heuristic",
|
||||||
|
"skill": {
|
||||||
|
"id": skill.id,
|
||||||
|
"summary": skill.summary,
|
||||||
|
"preferred_cli": skill.preferred_cli,
|
||||||
|
"preferred_agent": skill.preferred_agent,
|
||||||
|
},
|
||||||
|
"memory_hits": [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
pub const VERSION: &str = "0.1.0";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Serialize)]
|
||||||
|
pub struct Agent {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
pub role: &'static str,
|
||||||
|
pub primary_cli: &'static str,
|
||||||
|
pub manual_only: bool,
|
||||||
|
pub capabilities: &'static [&'static str],
|
||||||
|
pub preferred_layers: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Serialize)]
|
||||||
|
pub struct Skill {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub label: &'static str,
|
||||||
|
pub layer: &'static str,
|
||||||
|
pub visibility: &'static str,
|
||||||
|
pub summary: &'static str,
|
||||||
|
pub when_to_use: &'static [&'static str],
|
||||||
|
pub input_examples: &'static [&'static str],
|
||||||
|
pub output_expected: &'static [&'static str],
|
||||||
|
pub usage_prompt: &'static str,
|
||||||
|
pub keywords: &'static [&'static str],
|
||||||
|
pub aliases: &'static [&'static str],
|
||||||
|
pub preferred_cli: &'static str,
|
||||||
|
pub preferred_agent: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const AGENTS: &[Agent] = &[
|
||||||
|
Agent {
|
||||||
|
id: "planner",
|
||||||
|
label: "Planner",
|
||||||
|
role: "planner",
|
||||||
|
primary_cli: "claude",
|
||||||
|
manual_only: false,
|
||||||
|
capabilities: &["spec", "planning", "review", "summarization", "delegation"],
|
||||||
|
preferred_layers: &["reflection", "execution-prep"],
|
||||||
|
},
|
||||||
|
Agent {
|
||||||
|
id: "executor",
|
||||||
|
label: "Executor",
|
||||||
|
role: "executor",
|
||||||
|
primary_cli: "codex",
|
||||||
|
manual_only: false,
|
||||||
|
capabilities: &["implementation", "debugging", "refactor", "tooling", "ops"],
|
||||||
|
preferred_layers: &["execution"],
|
||||||
|
},
|
||||||
|
Agent {
|
||||||
|
id: "analyst",
|
||||||
|
label: "Analyst",
|
||||||
|
role: "analyst",
|
||||||
|
primary_cli: "vibe",
|
||||||
|
manual_only: false,
|
||||||
|
capabilities: &["brainstorm", "alternatives", "research", "comparison"],
|
||||||
|
preferred_layers: &["reflection"],
|
||||||
|
},
|
||||||
|
Agent {
|
||||||
|
id: "assistant",
|
||||||
|
label: "Assistant",
|
||||||
|
role: "assistant",
|
||||||
|
primary_cli: "copilot",
|
||||||
|
manual_only: true,
|
||||||
|
capabilities: &["interactive-help", "inline-suggestions"],
|
||||||
|
preferred_layers: &["manual"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const SKILLS: &[Skill] = &[
|
||||||
|
Skill {
|
||||||
|
id: "spec-planner",
|
||||||
|
label: "Spec Planner",
|
||||||
|
layer: "reflection",
|
||||||
|
visibility: "public",
|
||||||
|
summary: "Transformer une demande floue en spec exploitable avant de coder.",
|
||||||
|
when_to_use: &[
|
||||||
|
"quand on part d'une idee brute",
|
||||||
|
"quand il faut cadrer avant implementation",
|
||||||
|
"quand on veut eviter de coder trop tot",
|
||||||
|
],
|
||||||
|
input_examples: &[
|
||||||
|
"Je veux ajouter un systeme de notifications",
|
||||||
|
"On doit refondre l'auth OAuth",
|
||||||
|
],
|
||||||
|
output_expected: &[
|
||||||
|
"objectif",
|
||||||
|
"perimetre",
|
||||||
|
"hors_perimetre",
|
||||||
|
"contraintes",
|
||||||
|
"hypotheses",
|
||||||
|
"criteres_acceptation",
|
||||||
|
"risques",
|
||||||
|
"questions_ouvertes",
|
||||||
|
"plan_minimal",
|
||||||
|
],
|
||||||
|
usage_prompt: "Fais une spec executable a partir de cette demande. Identifie les zones floues, propose des hypotheses raisonnables, liste les criteres d'acceptation et termine par un plan d'implementation minimal.",
|
||||||
|
keywords: &[
|
||||||
|
"spec planner",
|
||||||
|
"spec-planner",
|
||||||
|
"spec",
|
||||||
|
"cadrer",
|
||||||
|
"clarifier",
|
||||||
|
"clarify",
|
||||||
|
"requirements",
|
||||||
|
"scope",
|
||||||
|
"perimetre",
|
||||||
|
"oauth",
|
||||||
|
"notifications",
|
||||||
|
"idea brute",
|
||||||
|
"je veux",
|
||||||
|
"i want",
|
||||||
|
"on doit",
|
||||||
|
"we need",
|
||||||
|
"refondre",
|
||||||
|
"before coding",
|
||||||
|
],
|
||||||
|
aliases: &["summarize", "summary", "brief"],
|
||||||
|
preferred_cli: "claude",
|
||||||
|
preferred_agent: "planner",
|
||||||
|
},
|
||||||
|
Skill {
|
||||||
|
id: "architecture-brainstorm",
|
||||||
|
label: "Architecture Brainstorm",
|
||||||
|
layer: "reflection",
|
||||||
|
visibility: "public",
|
||||||
|
summary: "Comparer plusieurs options d'architecture avant de choisir.",
|
||||||
|
when_to_use: &[
|
||||||
|
"quand plusieurs approches sont possibles",
|
||||||
|
"quand on veut comparer les trade-offs",
|
||||||
|
"quand il faut une vraie discussion d'architecture",
|
||||||
|
],
|
||||||
|
input_examples: &[
|
||||||
|
"Doit-on faire un backend event-driven ou un monolithe modulaire ?",
|
||||||
|
"Quel runtime pour le cockpit interactif ?",
|
||||||
|
],
|
||||||
|
output_expected: &[
|
||||||
|
"options",
|
||||||
|
"avantages",
|
||||||
|
"inconvenients",
|
||||||
|
"complexite",
|
||||||
|
"impacts_perf",
|
||||||
|
"impacts_securite",
|
||||||
|
"impacts_dx",
|
||||||
|
"recommendation",
|
||||||
|
"raisons_du_choix",
|
||||||
|
],
|
||||||
|
usage_prompt: "Analyse ce probleme comme un review partner senior. Propose plusieurs architectures possibles, compare les compromis, signale les pieges, puis recommande une option avec justification.",
|
||||||
|
keywords: &[
|
||||||
|
"architecture",
|
||||||
|
"brainstorm",
|
||||||
|
"architecture-brainstorm",
|
||||||
|
"trade-off",
|
||||||
|
"tradeoff",
|
||||||
|
"option",
|
||||||
|
"compare",
|
||||||
|
"alternatives",
|
||||||
|
"design choice",
|
||||||
|
"choix technique",
|
||||||
|
],
|
||||||
|
aliases: &["brainstorm"],
|
||||||
|
preferred_cli: "vibe",
|
||||||
|
preferred_agent: "analyst",
|
||||||
|
},
|
||||||
|
Skill {
|
||||||
|
id: "repo-onboarding",
|
||||||
|
label: "Repo Onboarding",
|
||||||
|
layer: "execution-prep",
|
||||||
|
visibility: "public",
|
||||||
|
summary: "Comprendre rapidement un repo avant la premiere intervention.",
|
||||||
|
when_to_use: &[
|
||||||
|
"quand on arrive sur un nouveau projet",
|
||||||
|
"quand il faut cartographier une codebase",
|
||||||
|
"quand on veut savoir ou modifier sans casser le reste",
|
||||||
|
],
|
||||||
|
input_examples: &[
|
||||||
|
"Explore ce repo comme si tu preparais une premiere intervention",
|
||||||
|
"Dis-moi ou intervenir pour cette tache",
|
||||||
|
],
|
||||||
|
output_expected: &[
|
||||||
|
"structure_repo",
|
||||||
|
"points_entree",
|
||||||
|
"modules_cles",
|
||||||
|
"conventions",
|
||||||
|
"commandes_utiles",
|
||||||
|
"zones_a_risque",
|
||||||
|
"strategie_modification",
|
||||||
|
],
|
||||||
|
usage_prompt: "Explore ce repo comme si tu preparais une premiere intervention. Resume sa structure, les modules critiques, les conventions implicites, les commandes de dev/test, puis dis ou intervenir pour cette tache.",
|
||||||
|
keywords: &[
|
||||||
|
"repo onboarding",
|
||||||
|
"repo-onboarding",
|
||||||
|
"onboarding",
|
||||||
|
"explore repo",
|
||||||
|
"new repo",
|
||||||
|
"codebase",
|
||||||
|
"structure du repo",
|
||||||
|
"points d'entree",
|
||||||
|
"where to change",
|
||||||
|
"cartographier",
|
||||||
|
],
|
||||||
|
aliases: &[],
|
||||||
|
preferred_cli: "claude",
|
||||||
|
preferred_agent: "planner",
|
||||||
|
},
|
||||||
|
Skill {
|
||||||
|
id: "task-decomposer",
|
||||||
|
label: "Task Decomposer",
|
||||||
|
layer: "execution-prep",
|
||||||
|
visibility: "public",
|
||||||
|
summary: "Transformer une spec ou direction choisie en plan d'execution operationnel.",
|
||||||
|
when_to_use: &[
|
||||||
|
"quand la direction est choisie",
|
||||||
|
"quand on veut passer du brainstorming a l'action",
|
||||||
|
"quand il faut un ordre d'execution clair",
|
||||||
|
],
|
||||||
|
input_examples: &[
|
||||||
|
"Decoupe cette refonte en sous-taches concretement executables",
|
||||||
|
"Fais-moi un plan d'implementation directement actionnable",
|
||||||
|
],
|
||||||
|
output_expected: &[
|
||||||
|
"etapes_ordonnees",
|
||||||
|
"dependances",
|
||||||
|
"quick_wins",
|
||||||
|
"parallelisation",
|
||||||
|
"tests",
|
||||||
|
"definition_of_done",
|
||||||
|
],
|
||||||
|
usage_prompt: "Decoupe cette tache en sous-taches concretes, ordonnees, avec dependances, risques, validations et tests associes. Le plan doit etre directement executable par un agent de code.",
|
||||||
|
keywords: &[
|
||||||
|
"task decomposer",
|
||||||
|
"task-decomposer",
|
||||||
|
"decompose",
|
||||||
|
"plan d'execution",
|
||||||
|
"execution plan",
|
||||||
|
"break down",
|
||||||
|
"subtasks",
|
||||||
|
"steps",
|
||||||
|
"roadmap",
|
||||||
|
"implementation plan",
|
||||||
|
],
|
||||||
|
aliases: &[],
|
||||||
|
preferred_cli: "claude",
|
||||||
|
preferred_agent: "planner",
|
||||||
|
},
|
||||||
|
Skill {
|
||||||
|
id: "pr-review-prep",
|
||||||
|
label: "PR Review Prep",
|
||||||
|
layer: "execution-prep",
|
||||||
|
visibility: "public",
|
||||||
|
summary: "Preparer une PR review-ready et anticiper les objections du reviewer.",
|
||||||
|
when_to_use: &[
|
||||||
|
"quand l'implementation est terminee",
|
||||||
|
"quand il faut une bonne PR",
|
||||||
|
"quand on veut preparer la review",
|
||||||
|
],
|
||||||
|
input_examples: &[
|
||||||
|
"Prepare une PR clean a partir de ces changements",
|
||||||
|
"Resume le pourquoi, le quoi et les risques pour le reviewer",
|
||||||
|
],
|
||||||
|
output_expected: &[
|
||||||
|
"resume_du_changement",
|
||||||
|
"impact_fonctionnel",
|
||||||
|
"impact_technique",
|
||||||
|
"migrations",
|
||||||
|
"tests",
|
||||||
|
"points_attention_reviewer",
|
||||||
|
"message_de_pr",
|
||||||
|
],
|
||||||
|
usage_prompt: "Prepare une PR review-ready a partir de ces changements. Resume le pourquoi, le quoi, les impacts, les risques, les tests effectues et les points a surveiller pour le reviewer.",
|
||||||
|
keywords: &[
|
||||||
|
"pr review prep",
|
||||||
|
"pr-review-prep",
|
||||||
|
"pr",
|
||||||
|
"pull request",
|
||||||
|
"review prep",
|
||||||
|
"release notes",
|
||||||
|
"review-ready",
|
||||||
|
"reviewer",
|
||||||
|
],
|
||||||
|
aliases: &["review"],
|
||||||
|
preferred_cli: "claude",
|
||||||
|
preferred_agent: "planner",
|
||||||
|
},
|
||||||
|
Skill {
|
||||||
|
id: "implementation",
|
||||||
|
label: "Implementation",
|
||||||
|
layer: "execution",
|
||||||
|
visibility: "internal",
|
||||||
|
summary: "Ship code changes and concrete fixes.",
|
||||||
|
when_to_use: &[
|
||||||
|
"quand il faut modifier du code maintenant",
|
||||||
|
"quand la spec est deja suffisamment claire",
|
||||||
|
],
|
||||||
|
input_examples: &["Fix the flaky test", "Implement the API endpoint"],
|
||||||
|
output_expected: &["code_changes", "validation", "tests"],
|
||||||
|
usage_prompt: "Implement the requested change directly and validate it.",
|
||||||
|
keywords: &[
|
||||||
|
"fix",
|
||||||
|
"implement",
|
||||||
|
"build",
|
||||||
|
"write",
|
||||||
|
"patch",
|
||||||
|
"feature",
|
||||||
|
"code",
|
||||||
|
],
|
||||||
|
aliases: &[],
|
||||||
|
preferred_cli: "codex",
|
||||||
|
preferred_agent: "executor",
|
||||||
|
},
|
||||||
|
Skill {
|
||||||
|
id: "debug-restoration",
|
||||||
|
label: "Debug Restoration",
|
||||||
|
layer: "execution",
|
||||||
|
visibility: "internal",
|
||||||
|
summary: "Investigate failures and restore a working state.",
|
||||||
|
when_to_use: &[
|
||||||
|
"quand quelque chose casse",
|
||||||
|
"quand il faut comprendre puis restaurer un etat sain",
|
||||||
|
],
|
||||||
|
input_examples: &[
|
||||||
|
"The pane dies instantly",
|
||||||
|
"This build started failing today",
|
||||||
|
],
|
||||||
|
output_expected: &["root_cause", "fix", "validation"],
|
||||||
|
usage_prompt: "Investigate the failure, isolate the cause, and restore a working state.",
|
||||||
|
keywords: &[
|
||||||
|
"bug", "debug", "failure", "broken", "error", "crash", "repair",
|
||||||
|
],
|
||||||
|
aliases: &["debug"],
|
||||||
|
preferred_cli: "codex",
|
||||||
|
preferred_agent: "executor",
|
||||||
|
},
|
||||||
|
Skill {
|
||||||
|
id: "ops-deployment",
|
||||||
|
label: "Ops Deployment",
|
||||||
|
layer: "execution",
|
||||||
|
visibility: "internal",
|
||||||
|
summary: "Handle shell, fleet, infra, network, and remote machine work.",
|
||||||
|
when_to_use: &[
|
||||||
|
"quand il faut deployer, scanner, installer, diagnostiquer des machines",
|
||||||
|
"quand la tache est principalement shell/infra",
|
||||||
|
],
|
||||||
|
input_examples: &[
|
||||||
|
"Deploy the runtime on all machines",
|
||||||
|
"Scan SSH hosts and configure the fleet",
|
||||||
|
],
|
||||||
|
output_expected: &["ops_actions", "machine_state", "validation"],
|
||||||
|
usage_prompt: "Handle the shell, infra, fleet, and remote machine operations safely.",
|
||||||
|
keywords: &["ssh", "shell", "fleet", "ops", "network", "infra", "deploy"],
|
||||||
|
aliases: &["ops"],
|
||||||
|
preferred_cli: "codex",
|
||||||
|
preferred_agent: "executor",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct SkillResolution {
|
||||||
|
pub skill: &'static Skill,
|
||||||
|
pub agent: &'static Agent,
|
||||||
|
pub cli: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn agent_by_id(agent_id: &str) -> Option<&'static Agent> {
|
||||||
|
AGENTS.iter().find(|agent| agent.id == agent_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn agent_for_cli(cli: &str) -> &'static Agent {
|
||||||
|
AGENTS
|
||||||
|
.iter()
|
||||||
|
.find(|agent| agent.primary_cli == cli)
|
||||||
|
.or_else(|| agent_by_id("executor"))
|
||||||
|
.expect("executor agent must exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn skill_by_id(skill_id: &str, include_internal: bool) -> Option<&'static Skill> {
|
||||||
|
let normalized = normalize_skill_id(skill_id);
|
||||||
|
SKILLS.iter().find(|skill| {
|
||||||
|
(include_internal || skill.visibility != "internal")
|
||||||
|
&& (normalized == skill.id
|
||||||
|
|| skill
|
||||||
|
.aliases
|
||||||
|
.iter()
|
||||||
|
.any(|alias| normalized == normalize_skill_id(alias)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_skill_and_agent(
|
||||||
|
goal: Option<&str>,
|
||||||
|
skill_id: Option<&str>,
|
||||||
|
agent_id: Option<&str>,
|
||||||
|
cli: Option<&str>,
|
||||||
|
) -> SkillResolution {
|
||||||
|
let skill = skill_id
|
||||||
|
.and_then(|value| skill_by_id(value, true))
|
||||||
|
.unwrap_or_else(|| match_skill(goal.unwrap_or(""), true));
|
||||||
|
|
||||||
|
if let Some(selected_cli) = cli {
|
||||||
|
let agent = agent_for_cli(selected_cli);
|
||||||
|
return SkillResolution {
|
||||||
|
skill,
|
||||||
|
agent,
|
||||||
|
cli: agent.primary_cli,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(selected_agent) = agent_id {
|
||||||
|
if let Some(agent) = agent_by_id(selected_agent) {
|
||||||
|
return SkillResolution {
|
||||||
|
skill,
|
||||||
|
agent,
|
||||||
|
cli: agent.primary_cli,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let agent = agent_by_id(skill.preferred_agent).expect("preferred agent must exist");
|
||||||
|
SkillResolution {
|
||||||
|
skill,
|
||||||
|
agent,
|
||||||
|
cli: skill.preferred_cli,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn match_skill(goal: &str, include_internal: bool) -> &'static Skill {
|
||||||
|
let goal_l = goal.to_lowercase();
|
||||||
|
let mut best = if include_internal {
|
||||||
|
skill_by_id("implementation", true).expect("implementation skill must exist")
|
||||||
|
} else {
|
||||||
|
skill_by_id("spec-planner", false).expect("spec-planner must exist")
|
||||||
|
};
|
||||||
|
let mut best_score = 0_i32;
|
||||||
|
|
||||||
|
for skill in SKILLS
|
||||||
|
.iter()
|
||||||
|
.filter(|skill| include_internal || skill.visibility != "internal")
|
||||||
|
{
|
||||||
|
let score = score_keywords(&goal_l, skill);
|
||||||
|
if score > best_score {
|
||||||
|
best_score = score;
|
||||||
|
best = skill;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_score == 0 {
|
||||||
|
if contains_any(
|
||||||
|
&goal_l,
|
||||||
|
&[
|
||||||
|
"repo",
|
||||||
|
"codebase",
|
||||||
|
"onboarding",
|
||||||
|
"where to change",
|
||||||
|
"first intervention",
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
return skill_by_id("repo-onboarding", include_internal)
|
||||||
|
.expect("repo-onboarding must exist");
|
||||||
|
}
|
||||||
|
if contains_any(
|
||||||
|
&goal_l,
|
||||||
|
&[
|
||||||
|
"je veux",
|
||||||
|
"i want",
|
||||||
|
"on doit",
|
||||||
|
"we need",
|
||||||
|
"idea brute",
|
||||||
|
"high level idea",
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
return skill_by_id("spec-planner", include_internal).expect("spec-planner must exist");
|
||||||
|
}
|
||||||
|
if contains_any(
|
||||||
|
&goal_l,
|
||||||
|
&["architecture", "trade-off", "tradeoff", "option", "compare"],
|
||||||
|
) {
|
||||||
|
return skill_by_id("architecture-brainstorm", include_internal)
|
||||||
|
.expect("architecture-brainstorm must exist");
|
||||||
|
}
|
||||||
|
if contains_any(
|
||||||
|
&goal_l,
|
||||||
|
&[
|
||||||
|
"plan",
|
||||||
|
"sub-task",
|
||||||
|
"subtask",
|
||||||
|
"decompose",
|
||||||
|
"step-by-step",
|
||||||
|
"roadmap",
|
||||||
|
],
|
||||||
|
) {
|
||||||
|
return skill_by_id("task-decomposer", include_internal)
|
||||||
|
.expect("task-decomposer must exist");
|
||||||
|
}
|
||||||
|
if contains_any(
|
||||||
|
&goal_l,
|
||||||
|
&["pr", "pull request", "reviewer", "release notes"],
|
||||||
|
) {
|
||||||
|
return skill_by_id("pr-review-prep", include_internal)
|
||||||
|
.expect("pr-review-prep must exist");
|
||||||
|
}
|
||||||
|
if include_internal
|
||||||
|
&& contains_any(&goal_l, &["bug", "crash", "failure", "broken", "error"])
|
||||||
|
{
|
||||||
|
return skill_by_id("debug-restoration", true).expect("debug-restoration must exist");
|
||||||
|
}
|
||||||
|
if include_internal
|
||||||
|
&& contains_any(
|
||||||
|
&goal_l,
|
||||||
|
&["ssh", "fleet", "deploy", "network", "machine", "infra"],
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return skill_by_id("ops-deployment", true).expect("ops-deployment must exist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_skill_id(value: &str) -> String {
|
||||||
|
value.trim().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_keywords(goal: &str, skill: &Skill) -> i32 {
|
||||||
|
let mut score = 0_i32;
|
||||||
|
for keyword in skill.keywords {
|
||||||
|
if goal.contains(keyword) {
|
||||||
|
score += if keyword.contains(' ') || keyword.contains('-') {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if goal.contains(skill.id) {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
for alias in skill.aliases {
|
||||||
|
if goal.contains(alias) {
|
||||||
|
score += 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains_any(goal: &str, tokens: &[&str]) -> bool {
|
||||||
|
tokens.iter().any(|token| goal.contains(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const MINIMAL_STACK: &[&str] = &["spec-planner", "architecture-brainstorm", "task-decomposer"];
|
||||||
|
|
||||||
|
pub const WORKFLOW_STACK: &[&str] = &[
|
||||||
|
"spec-planner",
|
||||||
|
"architecture-brainstorm",
|
||||||
|
"repo-onboarding",
|
||||||
|
"task-decomposer",
|
||||||
|
"pr-review-prep",
|
||||||
|
];
|
||||||
+449
@@ -0,0 +1,449 @@
|
|||||||
|
use std::process::{Command, ExitStatus};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::config::{MachineConfig, fleet_machine, load_fleet_config};
|
||||||
|
use crate::paths;
|
||||||
|
|
||||||
|
pub const ROLES: [&str; 4] = ["claude", "codex", "copilot", "vibe"];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PaneStatus {
|
||||||
|
pub session_name: String,
|
||||||
|
pub window_name: String,
|
||||||
|
pub pane_id: String,
|
||||||
|
pub pane_index: u32,
|
||||||
|
pub role: String,
|
||||||
|
pub pane_role: String,
|
||||||
|
pub pane_title: String,
|
||||||
|
pub pane_command: String,
|
||||||
|
pub active: bool,
|
||||||
|
pub dead: bool,
|
||||||
|
pub dead_status: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct MachineRuntimeStatus {
|
||||||
|
pub label: String,
|
||||||
|
pub target: String,
|
||||||
|
pub session: String,
|
||||||
|
pub reachable: bool,
|
||||||
|
pub attached_window: String,
|
||||||
|
pub session_exists: bool,
|
||||||
|
pub panes: Vec<PaneStatus>,
|
||||||
|
pub roles: std::collections::BTreeMap<String, Option<PaneStatus>>,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct FleetRuntimeStatus {
|
||||||
|
pub local_session: String,
|
||||||
|
pub machine_session: String,
|
||||||
|
pub fleet_session_exists: bool,
|
||||||
|
pub fleet_windows: Vec<String>,
|
||||||
|
pub focused_machine: Option<String>,
|
||||||
|
pub focused_role: Option<String>,
|
||||||
|
pub machines: Vec<MachineRuntimeStatus>,
|
||||||
|
pub fleet_stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct CommandResult {
|
||||||
|
pub argv: Vec<String>,
|
||||||
|
pub returncode: i32,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_status(
|
||||||
|
local_session: &str,
|
||||||
|
machine_session: &str,
|
||||||
|
) -> Result<FleetRuntimeStatus, String> {
|
||||||
|
let fleet = load_fleet_config()?;
|
||||||
|
let local_tmux = run_command(
|
||||||
|
&[
|
||||||
|
"tmux".to_string(),
|
||||||
|
"list-windows".to_string(),
|
||||||
|
"-t".to_string(),
|
||||||
|
local_session.to_string(),
|
||||||
|
"-F".to_string(),
|
||||||
|
"#{window_name}\t#{window_active}".to_string(),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let mut fleet_windows = Vec::new();
|
||||||
|
let mut focused_machine = None;
|
||||||
|
|
||||||
|
if local_tmux.returncode == 0 {
|
||||||
|
for raw in local_tmux.stdout.lines() {
|
||||||
|
let mut parts = raw.split('\t');
|
||||||
|
let window_name = parts.next().unwrap_or_default().to_string();
|
||||||
|
let active = parts.next().unwrap_or("0");
|
||||||
|
if !window_name.is_empty() {
|
||||||
|
if active == "1" {
|
||||||
|
focused_machine = Some(window_name.clone());
|
||||||
|
}
|
||||||
|
fleet_windows.push(window_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let machines = fleet
|
||||||
|
.machines
|
||||||
|
.iter()
|
||||||
|
.map(|machine| machine_tmux_status(machine, machine_session))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
let mut focused_role = None;
|
||||||
|
if let Some(machine_name) = focused_machine.as_deref() {
|
||||||
|
if let Some(machine) = machines
|
||||||
|
.iter()
|
||||||
|
.find(|machine| machine.label == machine_name)
|
||||||
|
{
|
||||||
|
for role in ROLES {
|
||||||
|
if machine
|
||||||
|
.roles
|
||||||
|
.get(role)
|
||||||
|
.and_then(|pane| pane.as_ref())
|
||||||
|
.map(|pane| pane.active)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
focused_role = Some(role.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(FleetRuntimeStatus {
|
||||||
|
local_session: local_session.to_string(),
|
||||||
|
machine_session: machine_session.to_string(),
|
||||||
|
fleet_session_exists: local_tmux.returncode == 0,
|
||||||
|
fleet_windows,
|
||||||
|
focused_machine,
|
||||||
|
focused_role,
|
||||||
|
machines,
|
||||||
|
fleet_stderr: local_tmux.stderr.trim().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cockpit_doctor(local_session: &str, machine_session: &str) -> Result<Value, String> {
|
||||||
|
let tmux = run_command(&["tmux".to_string(), "-V".to_string()], None)?;
|
||||||
|
let payload = runtime_status(local_session, machine_session)?;
|
||||||
|
Ok(json!({
|
||||||
|
"tmux": {
|
||||||
|
"available": tmux.returncode == 0,
|
||||||
|
"stdout": tmux.stdout.trim(),
|
||||||
|
"stderr": tmux.stderr.trim(),
|
||||||
|
},
|
||||||
|
"status": payload,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cockpit_open(
|
||||||
|
workspace: &str,
|
||||||
|
local_session: &str,
|
||||||
|
machine_session: &str,
|
||||||
|
recreate: bool,
|
||||||
|
remote_recreate: bool,
|
||||||
|
) -> Result<i32, String> {
|
||||||
|
let script = paths::repo_root().join("scripts/constant-fleet.sh");
|
||||||
|
let mut args = vec![
|
||||||
|
script.display().to_string(),
|
||||||
|
"--workspace".to_string(),
|
||||||
|
workspace.to_string(),
|
||||||
|
"--local-session".to_string(),
|
||||||
|
local_session.to_string(),
|
||||||
|
"--session".to_string(),
|
||||||
|
machine_session.to_string(),
|
||||||
|
];
|
||||||
|
if recreate {
|
||||||
|
args.push("--recreate".to_string());
|
||||||
|
}
|
||||||
|
if remote_recreate {
|
||||||
|
args.push("--remote-recreate".to_string());
|
||||||
|
}
|
||||||
|
Ok(run_status(&args, None)?.code().unwrap_or(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cockpit_attach(local_session: &str) -> Result<i32, String> {
|
||||||
|
let script = paths::repo_root().join("scripts/constant-fleet.sh");
|
||||||
|
let args = vec![
|
||||||
|
script.display().to_string(),
|
||||||
|
"--attach-only".to_string(),
|
||||||
|
"--local-session".to_string(),
|
||||||
|
local_session.to_string(),
|
||||||
|
];
|
||||||
|
Ok(run_status(&args, None)?.code().unwrap_or(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn focus_machine(
|
||||||
|
machine_label: &str,
|
||||||
|
pane_role: Option<&str>,
|
||||||
|
local_session: &str,
|
||||||
|
machine_session: &str,
|
||||||
|
) -> Result<CommandResult, String> {
|
||||||
|
let fleet = load_fleet_config()?;
|
||||||
|
let machine = fleet_machine(&fleet, machine_label)?;
|
||||||
|
let script = paths::repo_root().join("scripts/constant-machine.sh");
|
||||||
|
|
||||||
|
if let Some(role) = pane_role {
|
||||||
|
let result = run_machine_command(
|
||||||
|
machine,
|
||||||
|
&[
|
||||||
|
script.display().to_string(),
|
||||||
|
"--session".to_string(),
|
||||||
|
machine_session.to_string(),
|
||||||
|
"--focus-pane".to_string(),
|
||||||
|
role.to_string(),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
if result.returncode != 0 {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run_command(
|
||||||
|
&[
|
||||||
|
"tmux".to_string(),
|
||||||
|
"select-window".to_string(),
|
||||||
|
"-t".to_string(),
|
||||||
|
format!("{local_session}:{machine_label}"),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_to_pane(
|
||||||
|
machine_label: &str,
|
||||||
|
pane_role: &str,
|
||||||
|
command: &str,
|
||||||
|
machine_session: &str,
|
||||||
|
) -> Result<CommandResult, String> {
|
||||||
|
let fleet = load_fleet_config()?;
|
||||||
|
let machine = fleet_machine(&fleet, machine_label)?;
|
||||||
|
let script = paths::repo_root().join("scripts/constant-machine.sh");
|
||||||
|
run_machine_command(
|
||||||
|
machine,
|
||||||
|
&[
|
||||||
|
script.display().to_string(),
|
||||||
|
"--session".to_string(),
|
||||||
|
machine_session.to_string(),
|
||||||
|
"--send-pane".to_string(),
|
||||||
|
pane_role.to_string(),
|
||||||
|
"--command".to_string(),
|
||||||
|
command.to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capture_pane(
|
||||||
|
machine_label: &str,
|
||||||
|
pane_role: &str,
|
||||||
|
lines: u32,
|
||||||
|
machine_session: &str,
|
||||||
|
) -> Result<CommandResult, String> {
|
||||||
|
let fleet = load_fleet_config()?;
|
||||||
|
let machine = fleet_machine(&fleet, machine_label)?;
|
||||||
|
let script = paths::repo_root().join("scripts/constant-machine.sh");
|
||||||
|
run_machine_command(
|
||||||
|
machine,
|
||||||
|
&[
|
||||||
|
script.display().to_string(),
|
||||||
|
"--session".to_string(),
|
||||||
|
machine_session.to_string(),
|
||||||
|
"--capture-pane".to_string(),
|
||||||
|
pane_role.to_string(),
|
||||||
|
"--lines".to_string(),
|
||||||
|
lines.to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restart_pane(
|
||||||
|
machine_label: &str,
|
||||||
|
pane_role: &str,
|
||||||
|
machine_session: &str,
|
||||||
|
) -> Result<CommandResult, String> {
|
||||||
|
let fleet = load_fleet_config()?;
|
||||||
|
let machine = fleet_machine(&fleet, machine_label)?;
|
||||||
|
let script = paths::repo_root().join("scripts/constant-machine.sh");
|
||||||
|
run_machine_command(
|
||||||
|
machine,
|
||||||
|
&[
|
||||||
|
script.display().to_string(),
|
||||||
|
"--session".to_string(),
|
||||||
|
machine_session.to_string(),
|
||||||
|
"--restart-pane".to_string(),
|
||||||
|
pane_role.to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn machine_tmux_status(
|
||||||
|
machine: &MachineConfig,
|
||||||
|
session: &str,
|
||||||
|
) -> Result<MachineRuntimeStatus, String> {
|
||||||
|
let label = machine.label.clone();
|
||||||
|
let session_target = format!("{session}:{label}");
|
||||||
|
let result = if is_local_target(&machine.target) {
|
||||||
|
run_command(&tmux_list_command(&session_target), None)?
|
||||||
|
} else {
|
||||||
|
let inner = shlex_join(&tmux_list_command(&session_target));
|
||||||
|
run_command(&ssh_command(&machine.target, &inner), None)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let panes = if result.returncode == 0 {
|
||||||
|
parse_panes(&result.stdout)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let mut roles = std::collections::BTreeMap::new();
|
||||||
|
for role in ROLES {
|
||||||
|
roles.insert(
|
||||||
|
role.to_string(),
|
||||||
|
panes.iter().find(|pane| pane.role == role).cloned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MachineRuntimeStatus {
|
||||||
|
label: machine.label.clone(),
|
||||||
|
target: machine.target.clone(),
|
||||||
|
session: session_target,
|
||||||
|
reachable: result.returncode == 0 || is_local_target(&machine.target),
|
||||||
|
attached_window: machine.label.clone(),
|
||||||
|
session_exists: result.returncode == 0,
|
||||||
|
panes,
|
||||||
|
roles,
|
||||||
|
stderr: result.stderr.trim().to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_panes(stdout: &str) -> Vec<PaneStatus> {
|
||||||
|
let mut panes = stdout
|
||||||
|
.lines()
|
||||||
|
.filter_map(|raw| {
|
||||||
|
let parts = raw.split('\t').collect::<Vec<_>>();
|
||||||
|
if parts.len() != 10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let role = if !parts[4].is_empty() {
|
||||||
|
parts[4].to_string()
|
||||||
|
} else if ROLES.contains(&parts[5]) {
|
||||||
|
parts[5].to_string()
|
||||||
|
} else {
|
||||||
|
parts[6].to_string()
|
||||||
|
};
|
||||||
|
Some(PaneStatus {
|
||||||
|
session_name: parts[0].to_string(),
|
||||||
|
window_name: parts[1].to_string(),
|
||||||
|
pane_id: parts[2].to_string(),
|
||||||
|
pane_index: parts[3].parse().unwrap_or(0),
|
||||||
|
role,
|
||||||
|
pane_role: parts[4].to_string(),
|
||||||
|
pane_title: parts[5].to_string(),
|
||||||
|
pane_command: parts[6].to_string(),
|
||||||
|
active: parts[7] == "1",
|
||||||
|
dead: parts[8] == "1",
|
||||||
|
dead_status: parts[9].parse().unwrap_or(0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
panes.sort_by_key(|pane| pane.pane_index);
|
||||||
|
panes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmux_list_command(session_name: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"tmux".to_string(),
|
||||||
|
"list-panes".to_string(),
|
||||||
|
"-t".to_string(),
|
||||||
|
session_name.to_string(),
|
||||||
|
"-F".to_string(),
|
||||||
|
"#{session_name}\t#{window_name}\t#{pane_id}\t#{pane_index}\t#{@constant_role}\t#{pane_title}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}\t#{pane_dead_status}".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ssh_command(target: &str, inner: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"ssh".to_string(),
|
||||||
|
"-o".to_string(),
|
||||||
|
"BatchMode=yes".to_string(),
|
||||||
|
"-o".to_string(),
|
||||||
|
"ConnectTimeout=1".to_string(),
|
||||||
|
target.to_string(),
|
||||||
|
format!(
|
||||||
|
"PATH=\"$HOME/.local/bin:$HOME/.npm-global/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin\"; export PATH; {inner}"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_machine_command(machine: &MachineConfig, args: &[String]) -> Result<CommandResult, String> {
|
||||||
|
let mut with_env = vec![
|
||||||
|
"env".to_string(),
|
||||||
|
format!("ZELLIJ_AI_MACHINE_NAME={}", machine.label),
|
||||||
|
];
|
||||||
|
with_env.extend(args.iter().cloned());
|
||||||
|
if is_local_target(&machine.target) {
|
||||||
|
return run_command(&with_env, None);
|
||||||
|
}
|
||||||
|
let quoted = shlex_join(&with_env);
|
||||||
|
run_command(&ssh_command(&machine.target, "ed), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_command(args: &[String], cwd: Option<&str>) -> Result<CommandResult, String> {
|
||||||
|
let mut command = Command::new(&args[0]);
|
||||||
|
command.args(&args[1..]);
|
||||||
|
if let Some(cwd) = cwd {
|
||||||
|
command.current_dir(cwd);
|
||||||
|
}
|
||||||
|
let output = command
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("command failed to start {}: {err}", args[0]))?;
|
||||||
|
Ok(CommandResult {
|
||||||
|
argv: args.to_vec(),
|
||||||
|
returncode: output.status.code().unwrap_or(1),
|
||||||
|
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_status(args: &[String], cwd: Option<&str>) -> Result<ExitStatus, String> {
|
||||||
|
let mut command = Command::new(&args[0]);
|
||||||
|
command.args(&args[1..]);
|
||||||
|
if let Some(cwd) = cwd {
|
||||||
|
command.current_dir(cwd);
|
||||||
|
}
|
||||||
|
command
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("command failed to start {}: {err}", args[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_local_target(target: &str) -> bool {
|
||||||
|
if matches!(target, "local" | "localhost" | "127.0.0.1" | "::1") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let hostname = std::env::var("HOSTNAME").unwrap_or_default();
|
||||||
|
let short = hostname.split('.').next().unwrap_or_default();
|
||||||
|
target == hostname || target == short
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shlex_join(args: &[String]) -> String {
|
||||||
|
args.iter()
|
||||||
|
.map(|arg| shell_quote(arg))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_quote(value: &str) -> String {
|
||||||
|
if value.is_empty() {
|
||||||
|
return "''".to_string();
|
||||||
|
}
|
||||||
|
if value.chars().all(|ch| {
|
||||||
|
ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '/' | '.' | ':' | '@' | '=')
|
||||||
|
}) {
|
||||||
|
return value.to_string();
|
||||||
|
}
|
||||||
|
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||||
|
}
|
||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::paths;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MachineConfig {
|
||||||
|
pub label: String,
|
||||||
|
pub target: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_clis: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub manual_clis: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub backends: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FleetConfig {
|
||||||
|
pub version: u32,
|
||||||
|
pub local_machine: String,
|
||||||
|
pub repo_dir: String,
|
||||||
|
pub machines: Vec<MachineConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum EnableMlx {
|
||||||
|
Bool(bool),
|
||||||
|
String(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelRoleConfig {
|
||||||
|
pub role: String,
|
||||||
|
pub model_id: String,
|
||||||
|
pub max_tokens: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ModelsConfig {
|
||||||
|
pub version: u32,
|
||||||
|
pub enable_mlx: EnableMlx,
|
||||||
|
pub planner: ModelRoleConfig,
|
||||||
|
pub buddy: ModelRoleConfig,
|
||||||
|
pub verify: ModelRoleConfig,
|
||||||
|
pub fallback_mode: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryConfig {
|
||||||
|
pub version: u32,
|
||||||
|
pub local_store_path: String,
|
||||||
|
pub qdrant_url: String,
|
||||||
|
pub qdrant_collection: String,
|
||||||
|
pub workspace_enrollments: Vec<String>,
|
||||||
|
pub instruction_weights: BTreeMap<String, f64>,
|
||||||
|
pub max_chunks_per_query: u32,
|
||||||
|
pub vector_dimensions: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FleetConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
local_machine: "command-center".to_string(),
|
||||||
|
repo_dir: "$HOME/constant".to_string(),
|
||||||
|
machines: vec![
|
||||||
|
MachineConfig {
|
||||||
|
label: "command-center".to_string(),
|
||||||
|
target: "local".to_string(),
|
||||||
|
auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()],
|
||||||
|
manual_clis: vec!["copilot".into()],
|
||||||
|
backends: vec!["omc".into(), "cli-local".into(), "cockpit".into()],
|
||||||
|
},
|
||||||
|
MachineConfig {
|
||||||
|
label: "builder-a".to_string(),
|
||||||
|
target: "dev@builder-a".to_string(),
|
||||||
|
auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()],
|
||||||
|
manual_clis: vec!["copilot".into()],
|
||||||
|
backends: vec!["cli-ssh".into(), "cockpit".into()],
|
||||||
|
},
|
||||||
|
MachineConfig {
|
||||||
|
label: "builder-b".to_string(),
|
||||||
|
target: "dev@builder-b".to_string(),
|
||||||
|
auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()],
|
||||||
|
manual_clis: vec!["copilot".into()],
|
||||||
|
backends: vec!["cli-ssh".into(), "cockpit".into()],
|
||||||
|
},
|
||||||
|
MachineConfig {
|
||||||
|
label: "edge-a".to_string(),
|
||||||
|
target: "dev@edge-a".to_string(),
|
||||||
|
auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()],
|
||||||
|
manual_clis: vec!["copilot".into()],
|
||||||
|
backends: vec!["cli-ssh".into(), "cockpit".into()],
|
||||||
|
},
|
||||||
|
MachineConfig {
|
||||||
|
label: "lab-a".to_string(),
|
||||||
|
target: "dev@lab-a".to_string(),
|
||||||
|
auto_clis: vec!["codex".into(), "vibe".into(), "claude".into()],
|
||||||
|
manual_clis: vec!["copilot".into()],
|
||||||
|
backends: vec!["cli-ssh".into(), "cockpit".into()],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ModelsConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
enable_mlx: EnableMlx::String("auto".to_string()),
|
||||||
|
planner: ModelRoleConfig {
|
||||||
|
role: "planner".to_string(),
|
||||||
|
model_id: "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit".to_string(),
|
||||||
|
max_tokens: 900,
|
||||||
|
},
|
||||||
|
buddy: ModelRoleConfig {
|
||||||
|
role: "buddy".to_string(),
|
||||||
|
model_id: "mlx-community/Qwen2.5-Coder-3B-Instruct-4bit".to_string(),
|
||||||
|
max_tokens: 900,
|
||||||
|
},
|
||||||
|
verify: ModelRoleConfig {
|
||||||
|
role: "verify".to_string(),
|
||||||
|
model_id: "mlx-community-staging/Llama-3.2-3B-Instruct-mlx-4Bit".to_string(),
|
||||||
|
max_tokens: 700,
|
||||||
|
},
|
||||||
|
fallback_mode: "heuristic".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemoryConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut weights = BTreeMap::new();
|
||||||
|
weights.insert("workspace".to_string(), 1.0);
|
||||||
|
weights.insert("repo".to_string(), 0.85);
|
||||||
|
weights.insert("ancestor".to_string(), 0.65);
|
||||||
|
weights.insert("user".to_string(), 0.45);
|
||||||
|
weights.insert("default".to_string(), 0.2);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
version: 1,
|
||||||
|
local_store_path: paths::memory_store_path().display().to_string(),
|
||||||
|
qdrant_url: String::new(),
|
||||||
|
qdrant_collection: "constant_memory".to_string(),
|
||||||
|
workspace_enrollments: Vec::new(),
|
||||||
|
instruction_weights: weights,
|
||||||
|
max_chunks_per_query: 8,
|
||||||
|
vector_dimensions: 96,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_fleet_config() -> Result<FleetConfig, String> {
|
||||||
|
let mut config = load_with_legacy(
|
||||||
|
&paths::fleet_toml_path(),
|
||||||
|
&paths::fleet_json_path(),
|
||||||
|
&paths::fleet_yaml_path(),
|
||||||
|
FleetConfig::default(),
|
||||||
|
)?;
|
||||||
|
normalize_fleet(&mut config);
|
||||||
|
save_with_mirror(
|
||||||
|
&paths::fleet_toml_path(),
|
||||||
|
&paths::fleet_json_path(),
|
||||||
|
&config,
|
||||||
|
)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_models_config() -> Result<ModelsConfig, String> {
|
||||||
|
let config = load_with_legacy(
|
||||||
|
&paths::models_toml_path(),
|
||||||
|
&paths::models_json_path(),
|
||||||
|
&paths::models_yaml_path(),
|
||||||
|
ModelsConfig::default(),
|
||||||
|
)?;
|
||||||
|
save_with_mirror(
|
||||||
|
&paths::models_toml_path(),
|
||||||
|
&paths::models_json_path(),
|
||||||
|
&config,
|
||||||
|
)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_memory_config() -> Result<MemoryConfig, String> {
|
||||||
|
let config = load_with_legacy(
|
||||||
|
&paths::memory_toml_path(),
|
||||||
|
&paths::memory_json_path(),
|
||||||
|
&paths::memory_yaml_path(),
|
||||||
|
MemoryConfig::default(),
|
||||||
|
)?;
|
||||||
|
save_with_mirror(
|
||||||
|
&paths::memory_toml_path(),
|
||||||
|
&paths::memory_json_path(),
|
||||||
|
&config,
|
||||||
|
)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn save_memory_config(config: &MemoryConfig) -> Result<(), String> {
|
||||||
|
save_with_mirror(
|
||||||
|
&paths::memory_toml_path(),
|
||||||
|
&paths::memory_json_path(),
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fleet_machine<'a>(
|
||||||
|
fleet: &'a FleetConfig,
|
||||||
|
needle: &str,
|
||||||
|
) -> Result<&'a MachineConfig, String> {
|
||||||
|
fleet
|
||||||
|
.machines
|
||||||
|
.iter()
|
||||||
|
.find(|machine| machine.label == needle || machine.target == needle)
|
||||||
|
.ok_or_else(|| format!("Unknown machine: {needle}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_fleet(config: &mut FleetConfig) {
|
||||||
|
for machine in &mut config.machines {
|
||||||
|
machine.backends = machine
|
||||||
|
.backends
|
||||||
|
.iter()
|
||||||
|
.map(|backend| {
|
||||||
|
if backend == "zellij" {
|
||||||
|
"cockpit".to_string()
|
||||||
|
} else {
|
||||||
|
backend.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_with_legacy<T>(
|
||||||
|
toml_path: &Path,
|
||||||
|
json_path: &Path,
|
||||||
|
legacy_yaml_path: &Path,
|
||||||
|
default: T,
|
||||||
|
) -> Result<T, String>
|
||||||
|
where
|
||||||
|
T: Serialize + DeserializeOwned + Clone,
|
||||||
|
{
|
||||||
|
paths::ensure_runtime_dirs()?;
|
||||||
|
|
||||||
|
if toml_path.exists() {
|
||||||
|
return parse_toml_file(toml_path);
|
||||||
|
}
|
||||||
|
if json_path.exists() {
|
||||||
|
return parse_json_file(json_path);
|
||||||
|
}
|
||||||
|
if legacy_yaml_path.exists() {
|
||||||
|
return parse_json_file(legacy_yaml_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
save_with_mirror(toml_path, json_path, &default)?;
|
||||||
|
Ok(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_toml_file<T>(path: &Path) -> Result<T, String>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let text =
|
||||||
|
fs::read_to_string(path).map_err(|err| format!("cannot read {}: {err}", path.display()))?;
|
||||||
|
toml::from_str(&text).map_err(|err| format!("cannot parse {}: {err}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_json_file<T>(path: &Path) -> Result<T, String>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let text =
|
||||||
|
fs::read_to_string(path).map_err(|err| format!("cannot read {}: {err}", path.display()))?;
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("cannot parse {}: {err}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_with_mirror<T>(toml_path: &Path, json_path: &Path, payload: &T) -> Result<(), String>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
{
|
||||||
|
let toml_text =
|
||||||
|
toml::to_string_pretty(payload).map_err(|err| format!("cannot encode TOML: {err}"))?;
|
||||||
|
let json_text = serde_json::to_string_pretty(payload)
|
||||||
|
.map_err(|err| format!("cannot encode JSON: {err}"))?;
|
||||||
|
|
||||||
|
if let Some(parent) = toml_path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", parent.display()))?;
|
||||||
|
}
|
||||||
|
if let Some(parent) = json_path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", parent.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(toml_path, format!("{toml_text}\n"))
|
||||||
|
.map_err(|err| format!("cannot write {}: {err}", toml_path.display()))?;
|
||||||
|
fs::write(json_path, format!("{json_text}\n"))
|
||||||
|
.map_err(|err| format!("cannot write {}: {err}", json_path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+560
@@ -0,0 +1,560 @@
|
|||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::config::{FleetConfig, fleet_machine};
|
||||||
|
use crate::memory::summarize_mission_to_memory;
|
||||||
|
use crate::mission::plan_mission;
|
||||||
|
use crate::state::{
|
||||||
|
Mission, MissionStep, append_event, load_mission, mission_summary_value, save_mission,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn run_mission(mission: &mut Mission, fleet: &FleetConfig) -> Result<(Value, bool), String> {
|
||||||
|
if mission.steps.is_empty() {
|
||||||
|
plan_mission(mission, fleet)?;
|
||||||
|
save_mission(mission)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"mission.planned",
|
||||||
|
json!({
|
||||||
|
"plan": {
|
||||||
|
"title": mission.title,
|
||||||
|
"summary": mission.planner_summary,
|
||||||
|
"steps": mission.steps,
|
||||||
|
},
|
||||||
|
"buddy_review": mission.buddy_review,
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let Some(step_index) = first_active_step_index(mission) else {
|
||||||
|
mission.status = "done".to_string();
|
||||||
|
save_mission(mission)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"mission.done",
|
||||||
|
json!({"mission_id": mission.mission_id}),
|
||||||
|
)?;
|
||||||
|
return Ok((mission_summary_value(mission), true));
|
||||||
|
};
|
||||||
|
|
||||||
|
let step_snapshot = mission
|
||||||
|
.steps
|
||||||
|
.get(step_index)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "active step disappeared".to_string())?;
|
||||||
|
|
||||||
|
{
|
||||||
|
let step = mission
|
||||||
|
.steps
|
||||||
|
.get_mut(step_index)
|
||||||
|
.ok_or_else(|| "active step disappeared".to_string())?;
|
||||||
|
step.status = "running".to_string();
|
||||||
|
step.attempt += 1;
|
||||||
|
}
|
||||||
|
mission.status = "running".to_string();
|
||||||
|
save_mission(mission)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"step.started",
|
||||||
|
json!({
|
||||||
|
"step_id": step_snapshot.step_id,
|
||||||
|
"machine": step_snapshot.machine,
|
||||||
|
"backend": step_snapshot.backend,
|
||||||
|
"cli": step_snapshot.cli,
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let execution = execute_step(&step_snapshot, mission, fleet)?;
|
||||||
|
let artifact_path = artifact_for_attempt(mission, step_index, &execution)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"step.executed",
|
||||||
|
json!({
|
||||||
|
"step_id": step_snapshot.step_id,
|
||||||
|
"artifact": artifact_path,
|
||||||
|
"returncode": execution["returncode"],
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let verdict = verify_execution(mission, &step_snapshot, &execution);
|
||||||
|
let decision = verdict
|
||||||
|
.get("decision")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("failed");
|
||||||
|
|
||||||
|
{
|
||||||
|
let step = mission
|
||||||
|
.steps
|
||||||
|
.get_mut(step_index)
|
||||||
|
.ok_or_else(|| "active step disappeared".to_string())?;
|
||||||
|
step.artifact_refs.push(artifact_path.clone());
|
||||||
|
step.result_summary = verdict
|
||||||
|
.get("summary")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
step.extra
|
||||||
|
.insert("verification".to_string(), verdict.clone());
|
||||||
|
|
||||||
|
match decision {
|
||||||
|
"done" => step.status = "done".to_string(),
|
||||||
|
"retry" if step.attempt < 2 => step.status = "pending".to_string(),
|
||||||
|
"needs_human" => step.status = "needs_human".to_string(),
|
||||||
|
_ => step.status = "failed".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match decision {
|
||||||
|
"done" => {
|
||||||
|
save_mission(mission)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"step.verified",
|
||||||
|
json!({"step_id": step_snapshot.step_id, "verdict": verdict}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"retry" => {
|
||||||
|
mission.status = "planned".to_string();
|
||||||
|
save_mission(mission)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"step.verified",
|
||||||
|
json!({"step_id": step_snapshot.step_id, "verdict": verdict}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
"needs_human" => {
|
||||||
|
mission.status = "needs_human".to_string();
|
||||||
|
save_mission(mission)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"step.needs_human",
|
||||||
|
json!({"step_id": step_snapshot.step_id, "summary": verdict["summary"]}),
|
||||||
|
)?;
|
||||||
|
let summary = summarize_mission(mission)?;
|
||||||
|
return Ok((json!({"summary": summary, "verdict": verdict}), false));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
mission.status = "failed".to_string();
|
||||||
|
save_mission(mission)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"step.failed",
|
||||||
|
json!({"step_id": step_snapshot.step_id, "summary": verdict["summary"]}),
|
||||||
|
)?;
|
||||||
|
let summary = summarize_mission(mission)?;
|
||||||
|
return Ok((json!({"summary": summary, "verdict": verdict}), false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_mission(
|
||||||
|
mission: &mut Mission,
|
||||||
|
step_id: Option<&str>,
|
||||||
|
) -> Result<(Value, bool), String> {
|
||||||
|
let step_index = if let Some(step_id) = step_id {
|
||||||
|
mission
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.step_id == step_id)
|
||||||
|
} else if mission.steps.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(mission.steps.len() - 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(step_index) = step_index else {
|
||||||
|
return Err("No step artifact available to verify.".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let step_snapshot = mission
|
||||||
|
.steps
|
||||||
|
.get(step_index)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "step not found".to_string())?;
|
||||||
|
|
||||||
|
let artifact_path = step_snapshot
|
||||||
|
.artifact_refs
|
||||||
|
.last()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "No step artifact available to verify.".to_string())?;
|
||||||
|
let artifact_text = std::fs::read_to_string(&artifact_path)
|
||||||
|
.map_err(|err| format!("cannot read {artifact_path}: {err}"))?;
|
||||||
|
let execution: Value = serde_json::from_str(&artifact_text)
|
||||||
|
.map_err(|err| format!("cannot parse {artifact_path}: {err}"))?;
|
||||||
|
let verdict = verify_execution(mission, &step_snapshot, &execution);
|
||||||
|
|
||||||
|
if let Some(step) = mission.steps.get_mut(step_index) {
|
||||||
|
step.result_summary = verdict
|
||||||
|
.get("summary")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
step.extra
|
||||||
|
.insert("verification".to_string(), verdict.clone());
|
||||||
|
}
|
||||||
|
save_mission(mission)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"mission.verify",
|
||||||
|
json!({"step_id": step_snapshot.step_id, "verdict": verdict}),
|
||||||
|
)?;
|
||||||
|
let done = verdict
|
||||||
|
.get("decision")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|value| value == "done")
|
||||||
|
.unwrap_or(false);
|
||||||
|
Ok((
|
||||||
|
json!({"step_id": step_snapshot.step_id, "verdict": verdict}),
|
||||||
|
done,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summarize_mission(mission: &mut Mission) -> Result<Value, String> {
|
||||||
|
let status_mix = status_mix(mission);
|
||||||
|
let status_mix_text = status_mix
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| format!("{key}={}", value.as_u64().unwrap_or(0)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let routes = mission
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.map(|step| {
|
||||||
|
format!(
|
||||||
|
"{}={}/{}/{}:{}",
|
||||||
|
step.step_id, step.machine, step.cli, step.backend, step.status
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let summary = format!(
|
||||||
|
"Mission {} ended as {}. Steps={}. Status mix={}. Routes: {}.",
|
||||||
|
mission.title,
|
||||||
|
mission.status,
|
||||||
|
mission.steps.len(),
|
||||||
|
status_mix_text,
|
||||||
|
if routes.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
routes.join("; ")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let artifact_name = format!("summary-{}.json", mission.mission_id);
|
||||||
|
let artifact_payload = json!({
|
||||||
|
"mission_id": mission.mission_id,
|
||||||
|
"title": mission.title,
|
||||||
|
"status": mission.status,
|
||||||
|
"summary": summary,
|
||||||
|
"status_mix": status_mix,
|
||||||
|
"routes": routes,
|
||||||
|
});
|
||||||
|
let artifact_path = summary_artifact_path(mission, &artifact_name, &artifact_payload)?;
|
||||||
|
mission.artifacts.push(artifact_path.clone());
|
||||||
|
let memory_summary = summarize_mission_to_memory(mission)?;
|
||||||
|
save_mission(mission)?;
|
||||||
|
append_event(
|
||||||
|
&mission.mission_id,
|
||||||
|
"mission.summarized",
|
||||||
|
json!({
|
||||||
|
"artifact": artifact_payload.clone(),
|
||||||
|
"memory": memory_summary,
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"mission_id": mission.mission_id,
|
||||||
|
"title": mission.title,
|
||||||
|
"status": mission.status,
|
||||||
|
"summary": summary,
|
||||||
|
"artifact": artifact_path,
|
||||||
|
"status_mix": status_mix,
|
||||||
|
"routes": routes,
|
||||||
|
"memory": memory_summary,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execute_step(
|
||||||
|
step: &MissionStep,
|
||||||
|
mission: &Mission,
|
||||||
|
fleet: &FleetConfig,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let machine = fleet_machine(fleet, &step.machine)?;
|
||||||
|
if step.cli == "copilot" {
|
||||||
|
return Ok(json!({
|
||||||
|
"argv": [],
|
||||||
|
"returncode": 1,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "copilot is manual-only in Constant v1",
|
||||||
|
"duration_s": 0.0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(step.backend.as_str(), "cockpit" | "zellij") {
|
||||||
|
let cockpit = crate::paths::repo_root().join("scripts/constant-fleet.sh");
|
||||||
|
return Ok(json!({
|
||||||
|
"argv": [cockpit.display().to_string(), "--workspace", mission.workspace],
|
||||||
|
"returncode": 0,
|
||||||
|
"stdout": format!("Open cockpit manually with: {} --workspace {}", cockpit.display(), mission.workspace),
|
||||||
|
"stderr": "",
|
||||||
|
"duration_s": 0.0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = build_local_command(step, &mission.workspace)?;
|
||||||
|
if matches!(step.backend.as_str(), "cli-local" | "omc") {
|
||||||
|
return run_command(&command, Some(&mission.workspace));
|
||||||
|
}
|
||||||
|
if step.backend == "cli-ssh" {
|
||||||
|
let quoted = shlex_join(&command);
|
||||||
|
let remote_shell = format!(
|
||||||
|
"export PATH=\"$HOME/.local/bin:$HOME/.npm-global/bin:$PATH\"; cd {} && {}",
|
||||||
|
shell_quote(&mission.workspace),
|
||||||
|
quoted
|
||||||
|
);
|
||||||
|
let ssh_cmd = vec![
|
||||||
|
"ssh".to_string(),
|
||||||
|
machine.target.clone(),
|
||||||
|
"bash".to_string(),
|
||||||
|
"-lc".to_string(),
|
||||||
|
remote_shell,
|
||||||
|
];
|
||||||
|
return run_command(&ssh_cmd, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Unsupported backend: {}", step.backend))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_execution(mission: &Mission, step: &MissionStep, execution: &Value) -> Value {
|
||||||
|
let returncode = execution
|
||||||
|
.get("returncode")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.unwrap_or(1);
|
||||||
|
let stdout = execution
|
||||||
|
.get("stdout")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let stderr = execution
|
||||||
|
.get("stderr")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let combined = format!("{stdout}\n{stderr}").to_lowercase();
|
||||||
|
|
||||||
|
let (decision, summary, confidence) = if returncode == 0
|
||||||
|
&& !combined.contains("open cockpit manually")
|
||||||
|
&& !combined.contains("manual-only")
|
||||||
|
{
|
||||||
|
(
|
||||||
|
"done",
|
||||||
|
format!(
|
||||||
|
"Step {} completed successfully on {} with {}.",
|
||||||
|
step.step_id, step.machine, step.cli
|
||||||
|
),
|
||||||
|
"medium",
|
||||||
|
)
|
||||||
|
} else if combined.contains("open cockpit manually")
|
||||||
|
|| combined.contains("manual-only")
|
||||||
|
|| combined.contains("device-auth")
|
||||||
|
|| combined.contains("login")
|
||||||
|
|| combined.contains("auth")
|
||||||
|
{
|
||||||
|
(
|
||||||
|
"needs_human",
|
||||||
|
format!(
|
||||||
|
"Step {} needs human attention: {}",
|
||||||
|
step.step_id,
|
||||||
|
first_nonempty_line(stderr, stdout)
|
||||||
|
),
|
||||||
|
"high",
|
||||||
|
)
|
||||||
|
} else if step.attempt < 1 {
|
||||||
|
(
|
||||||
|
"retry",
|
||||||
|
format!(
|
||||||
|
"Step {} failed once and should retry: {}",
|
||||||
|
step.step_id,
|
||||||
|
first_nonempty_line(stderr, stdout)
|
||||||
|
),
|
||||||
|
"medium",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
"failed",
|
||||||
|
format!(
|
||||||
|
"Step {} failed after retry budget: {}",
|
||||||
|
step.step_id,
|
||||||
|
first_nonempty_line(stderr, stdout)
|
||||||
|
),
|
||||||
|
"high",
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"decision": decision,
|
||||||
|
"summary": summary,
|
||||||
|
"confidence": confidence,
|
||||||
|
"mission_status": mission.status,
|
||||||
|
"machine": step.machine,
|
||||||
|
"cli": step.cli,
|
||||||
|
"backend": step.backend,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_local_command(step: &MissionStep, workspace: &str) -> Result<Vec<String>, String> {
|
||||||
|
match step.backend.as_str() {
|
||||||
|
"omc" => {
|
||||||
|
if !matches!(step.cli.as_str(), "claude" | "codex") {
|
||||||
|
return Err(format!(
|
||||||
|
"OMC backend only supports claude/codex in v1, not {}",
|
||||||
|
step.cli
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(vec![
|
||||||
|
"omc".to_string(),
|
||||||
|
"ask".to_string(),
|
||||||
|
step.cli.clone(),
|
||||||
|
"--print".to_string(),
|
||||||
|
step.prompt.clone(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
_ if step.cli == "claude" => Ok(vec![
|
||||||
|
"claude".to_string(),
|
||||||
|
"-p".to_string(),
|
||||||
|
"--output-format".to_string(),
|
||||||
|
"json".to_string(),
|
||||||
|
"--permission-mode".to_string(),
|
||||||
|
"acceptEdits".to_string(),
|
||||||
|
step.prompt.clone(),
|
||||||
|
]),
|
||||||
|
_ if step.cli == "codex" => Ok(vec![
|
||||||
|
"codex".to_string(),
|
||||||
|
"exec".to_string(),
|
||||||
|
"--json".to_string(),
|
||||||
|
"--full-auto".to_string(),
|
||||||
|
"--skip-git-repo-check".to_string(),
|
||||||
|
"-C".to_string(),
|
||||||
|
workspace.to_string(),
|
||||||
|
step.prompt.clone(),
|
||||||
|
]),
|
||||||
|
_ if step.cli == "vibe" => Ok(vec![
|
||||||
|
"vibe".to_string(),
|
||||||
|
"-p".to_string(),
|
||||||
|
step.prompt.clone(),
|
||||||
|
"--output".to_string(),
|
||||||
|
"json".to_string(),
|
||||||
|
"--workdir".to_string(),
|
||||||
|
workspace.to_string(),
|
||||||
|
]),
|
||||||
|
_ => Err(format!("Unsupported auto CLI: {}", step.cli)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_command(args: &[String], cwd: Option<&str>) -> Result<Value, String> {
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut command = Command::new(&args[0]);
|
||||||
|
command.args(&args[1..]);
|
||||||
|
if let Some(cwd) = cwd {
|
||||||
|
command.current_dir(cwd);
|
||||||
|
}
|
||||||
|
command.stdin(Stdio::null());
|
||||||
|
let output = command
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("command failed to start {}: {err}", args[0]))?;
|
||||||
|
Ok(json!({
|
||||||
|
"argv": args,
|
||||||
|
"returncode": output.status.code().unwrap_or(1),
|
||||||
|
"stdout": String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
"stderr": String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
"duration_s": started.elapsed().as_secs_f64(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artifact_for_attempt(
|
||||||
|
mission: &mut Mission,
|
||||||
|
step_index: usize,
|
||||||
|
execution: &Value,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let step = mission
|
||||||
|
.steps
|
||||||
|
.get(step_index)
|
||||||
|
.ok_or_else(|| "step not found".to_string())?;
|
||||||
|
let artifact_dir = crate::paths::mission_artifacts_dir(&mission.mission_id);
|
||||||
|
std::fs::create_dir_all(&artifact_dir)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", artifact_dir.display()))?;
|
||||||
|
let path = artifact_dir.join(format!("{}-attempt-{}.json", step.step_id, step.attempt));
|
||||||
|
let text = serde_json::to_string_pretty(execution)
|
||||||
|
.map_err(|err| format!("cannot encode artifact: {err}"))?;
|
||||||
|
std::fs::write(&path, format!("{text}\n"))
|
||||||
|
.map_err(|err| format!("cannot write {}: {err}", path.display()))?;
|
||||||
|
let artifact_path = path.display().to_string();
|
||||||
|
mission.artifacts.push(artifact_path.clone());
|
||||||
|
Ok(artifact_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summary_artifact_path(mission: &Mission, name: &str, payload: &Value) -> Result<String, String> {
|
||||||
|
let artifact_dir = crate::paths::mission_artifacts_dir(&mission.mission_id);
|
||||||
|
std::fs::create_dir_all(&artifact_dir)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", artifact_dir.display()))?;
|
||||||
|
let path = artifact_dir.join(name);
|
||||||
|
let text = serde_json::to_string_pretty(payload)
|
||||||
|
.map_err(|err| format!("cannot encode summary artifact: {err}"))?;
|
||||||
|
std::fs::write(&path, format!("{text}\n"))
|
||||||
|
.map_err(|err| format!("cannot write {}: {err}", path.display()))?;
|
||||||
|
Ok(path.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_active_step_index(mission: &Mission) -> Option<usize> {
|
||||||
|
mission
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.position(|step| !matches!(step.status.as_str(), "done" | "failed" | "needs_human"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_mix(mission: &Mission) -> serde_json::Map<String, Value> {
|
||||||
|
let mut counts = std::collections::BTreeMap::<String, u64>::new();
|
||||||
|
for step in &mission.steps {
|
||||||
|
*counts.entry(step.status.clone()).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| (key, json!(value)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shlex_join(args: &[String]) -> String {
|
||||||
|
args.iter()
|
||||||
|
.map(|arg| shell_quote(arg))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_quote(value: &str) -> String {
|
||||||
|
if value.is_empty() {
|
||||||
|
return "''".to_string();
|
||||||
|
}
|
||||||
|
if value
|
||||||
|
.chars()
|
||||||
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '/' | '.' | ':' | '@'))
|
||||||
|
{
|
||||||
|
return value.to_string();
|
||||||
|
}
|
||||||
|
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_nonempty_line(stderr: &str, stdout: &str) -> String {
|
||||||
|
stderr
|
||||||
|
.lines()
|
||||||
|
.chain(stdout.lines())
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| !line.is_empty())
|
||||||
|
.unwrap_or("no details")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn summarize_mission_command(mission_id: &str) -> Result<Value, String> {
|
||||||
|
let mut mission = load_mission(mission_id)?;
|
||||||
|
summarize_mission(&mut mission)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::paths;
|
||||||
|
|
||||||
|
pub fn fleet_check() -> Result<Value, String> {
|
||||||
|
let script = paths::repo_root().join("scripts/constant-fleet-install.sh");
|
||||||
|
let output = std::process::Command::new(script)
|
||||||
|
.arg("check")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("cannot run fleet check: {err}"))?;
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|
||||||
|
let mut machines = Vec::new();
|
||||||
|
let mut current = serde_json::Map::new();
|
||||||
|
for raw_line in stdout.lines() {
|
||||||
|
let line = raw_line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line.starts_with("===") && line.contains('(') {
|
||||||
|
if !current.is_empty() {
|
||||||
|
machines.push(Value::Object(current.clone()));
|
||||||
|
current.clear();
|
||||||
|
}
|
||||||
|
let label = line.split_whitespace().nth(1).unwrap_or_default();
|
||||||
|
current.insert("label".to_string(), Value::String(label.to_string()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
current.insert(key.to_string(), Value::String(value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
machines.push(Value::Object(current));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"returncode": output.status.code().unwrap_or(1),
|
||||||
|
"machines": machines,
|
||||||
|
"stderr": stderr.trim(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bridge_sync() -> Result<Value, String> {
|
||||||
|
let script = paths::repo_root().join("scripts/ai-bridge.sh");
|
||||||
|
let output = std::process::Command::new(script)
|
||||||
|
.arg("sync")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("cannot run bridge sync: {err}"))?;
|
||||||
|
Ok(json!({
|
||||||
|
"returncode": output.status.code().unwrap_or(1),
|
||||||
|
"stdout": String::from_utf8_lossy(&output.stdout),
|
||||||
|
"stderr": String::from_utf8_lossy(&output.stderr),
|
||||||
|
}))
|
||||||
|
}
|
||||||
+1419
File diff suppressed because it is too large
Load Diff
+1302
File diff suppressed because it is too large
Load Diff
+228
@@ -0,0 +1,228 @@
|
|||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
use crate::capabilities::{agent_for_cli, resolve_skill_and_agent, skill_by_id};
|
||||||
|
use crate::config::{FleetConfig, MachineConfig, fleet_machine};
|
||||||
|
use crate::state::{Mission, MissionStep};
|
||||||
|
|
||||||
|
pub fn plan_mission(mission: &mut Mission, fleet: &FleetConfig) -> Result<Value, String> {
|
||||||
|
let overrides = &mission.routing_overrides;
|
||||||
|
let override_skill = string_override(overrides, "skill");
|
||||||
|
let override_agent = string_override(overrides, "agent");
|
||||||
|
let override_cli = string_override(overrides, "cli");
|
||||||
|
let resolution = resolve_skill_and_agent(
|
||||||
|
Some(&mission.goal),
|
||||||
|
override_skill.as_deref(),
|
||||||
|
override_agent.as_deref(),
|
||||||
|
override_cli.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let machine_label =
|
||||||
|
string_override(overrides, "machine").unwrap_or_else(|| fleet.local_machine.clone());
|
||||||
|
let machine_cfg = fleet_machine(fleet, &machine_label)?;
|
||||||
|
let backend =
|
||||||
|
string_override(overrides, "backend").unwrap_or_else(|| default_backend(machine_cfg));
|
||||||
|
|
||||||
|
let step = MissionStep {
|
||||||
|
step_id: "step-1".to_string(),
|
||||||
|
kind: "single-shot".to_string(),
|
||||||
|
prompt: mission.goal.clone(),
|
||||||
|
machine: machine_cfg.label.clone(),
|
||||||
|
backend,
|
||||||
|
cli: resolution.cli.to_string(),
|
||||||
|
agent: resolution.agent.id.to_string(),
|
||||||
|
agent_role: Some(resolution.agent.role.to_string()),
|
||||||
|
skill: Some(resolution.skill.id.to_string()),
|
||||||
|
skill_summary: Some(resolution.skill.summary.to_string()),
|
||||||
|
status: "pending".to_string(),
|
||||||
|
attempt: 0,
|
||||||
|
depends_on: Vec::new(),
|
||||||
|
artifact_refs: Vec::new(),
|
||||||
|
result_summary: String::new(),
|
||||||
|
extra: Map::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mission.steps = vec![step];
|
||||||
|
mission.status = "planned".to_string();
|
||||||
|
mission.title = planned_title(&mission.goal, &mission.title);
|
||||||
|
mission.planner_summary = Some(format!(
|
||||||
|
"Rust heuristic planner selected {} / {} on {} via {}.",
|
||||||
|
resolution.skill.id, resolution.cli, machine_cfg.label, mission.steps[0].backend
|
||||||
|
));
|
||||||
|
let buddy_review = json!({
|
||||||
|
"mode": "rust-heuristic",
|
||||||
|
"verdict": "agree",
|
||||||
|
"confidence": "medium",
|
||||||
|
"why": "Matched the goal against the skill registry and routed to the preferred agent/CLI stack.",
|
||||||
|
"change": {
|
||||||
|
"machine": mission.steps[0].machine,
|
||||||
|
"cli": mission.steps[0].cli,
|
||||||
|
"backend": mission.steps[0].backend,
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"store": false,
|
||||||
|
"kind": "plan"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mission.buddy_review = Some(buddy_review.clone());
|
||||||
|
Ok(buddy_review)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retry_mission(mission: &mut Mission, step_id: Option<&str>) -> Result<Value, String> {
|
||||||
|
let step_index = if let Some(target) = step_id {
|
||||||
|
mission
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.step_id == target)
|
||||||
|
} else {
|
||||||
|
first_active_step_index(mission).or_else(|| mission.steps.len().checked_sub(1))
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(step_index) = step_index else {
|
||||||
|
return Err("No step to retry.".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let step = mission
|
||||||
|
.steps
|
||||||
|
.get_mut(step_index)
|
||||||
|
.ok_or_else(|| "No step to retry.".to_string())?;
|
||||||
|
step.status = "pending".to_string();
|
||||||
|
let step_id_value = step.step_id.clone();
|
||||||
|
mission.status = "planned".to_string();
|
||||||
|
Ok(json!({ "step_id": step_id_value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delegate_step(
|
||||||
|
mission: &mut Mission,
|
||||||
|
step_id: Option<&str>,
|
||||||
|
machine: Option<&str>,
|
||||||
|
backend: Option<&str>,
|
||||||
|
cli: Option<&str>,
|
||||||
|
agent: Option<&str>,
|
||||||
|
skill: Option<&str>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let step_index = if let Some(target) = step_id {
|
||||||
|
mission
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.position(|entry| entry.step_id == target)
|
||||||
|
} else {
|
||||||
|
first_active_step_index(mission)
|
||||||
|
};
|
||||||
|
let Some(step_index) = step_index else {
|
||||||
|
return Err("No active step to delegate.".to_string());
|
||||||
|
};
|
||||||
|
|
||||||
|
let goal_fallback = mission.goal.clone();
|
||||||
|
let step = mission
|
||||||
|
.steps
|
||||||
|
.get_mut(step_index)
|
||||||
|
.ok_or_else(|| "No active step to delegate.".to_string())?;
|
||||||
|
|
||||||
|
if let Some(machine) = machine {
|
||||||
|
step.machine = machine.to_string();
|
||||||
|
}
|
||||||
|
if let Some(backend) = backend {
|
||||||
|
step.backend = backend.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
if skill.is_some() || agent.is_some() || cli.is_some() {
|
||||||
|
let resolved_cli = if skill.is_none() {
|
||||||
|
cli.or_else(|| (!step.cli.is_empty()).then_some(step.cli.as_str()))
|
||||||
|
} else {
|
||||||
|
cli
|
||||||
|
};
|
||||||
|
let resolved_agent = if skill.is_none() {
|
||||||
|
agent.or_else(|| (!step.agent.is_empty()).then_some(step.agent.as_str()))
|
||||||
|
} else {
|
||||||
|
agent
|
||||||
|
};
|
||||||
|
let resolved = resolve_skill_and_agent(
|
||||||
|
Some(if step.prompt.is_empty() {
|
||||||
|
&goal_fallback
|
||||||
|
} else {
|
||||||
|
&step.prompt
|
||||||
|
}),
|
||||||
|
skill.or(step.skill.as_deref()),
|
||||||
|
resolved_agent,
|
||||||
|
resolved_cli,
|
||||||
|
);
|
||||||
|
step.skill = Some(resolved.skill.id.to_string());
|
||||||
|
step.skill_summary = Some(resolved.skill.summary.to_string());
|
||||||
|
step.agent = resolved.agent.id.to_string();
|
||||||
|
step.agent_role = Some(resolved.agent.role.to_string());
|
||||||
|
step.cli = resolved.cli.to_string();
|
||||||
|
} else if !step.cli.is_empty() && step.agent.is_empty() {
|
||||||
|
let mapped = agent_for_cli(&step.cli);
|
||||||
|
step.agent = mapped.id.to_string();
|
||||||
|
step.agent_role = Some(mapped.role.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(skill_id) = step.skill.clone() {
|
||||||
|
if let Some(mapped_skill) = skill_by_id(&skill_id, true) {
|
||||||
|
step.skill_summary = Some(mapped_skill.summary.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
step.status = "pending".to_string();
|
||||||
|
let payload = json!({
|
||||||
|
"step_id": step.step_id,
|
||||||
|
"machine": step.machine,
|
||||||
|
"backend": step.backend,
|
||||||
|
"cli": step.cli,
|
||||||
|
"agent": step.agent,
|
||||||
|
"skill": step.skill,
|
||||||
|
});
|
||||||
|
mission.status = "planned".to_string();
|
||||||
|
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_override(payload: &Map<String, Value>, key: &str) -> Option<String> {
|
||||||
|
payload
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_backend(machine: &MachineConfig) -> String {
|
||||||
|
let local = matches!(
|
||||||
|
machine.target.as_str(),
|
||||||
|
"local" | "localhost" | "127.0.0.1" | "::1"
|
||||||
|
);
|
||||||
|
let preferred = if local {
|
||||||
|
["cli-local", "omc", "cockpit"]
|
||||||
|
} else {
|
||||||
|
["cli-ssh", "cockpit", "cli-local"]
|
||||||
|
};
|
||||||
|
|
||||||
|
for backend in preferred {
|
||||||
|
if machine
|
||||||
|
.backends
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate == backend)
|
||||||
|
{
|
||||||
|
return backend.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
machine
|
||||||
|
.backends
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| if local { "cli-local" } else { "cli-ssh" }.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_active_step_index(mission: &Mission) -> Option<usize> {
|
||||||
|
mission
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.position(|step| !matches!(step.status.as_str(), "done" | "failed" | "needs_human"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn planned_title(goal: &str, fallback: &str) -> String {
|
||||||
|
let head = goal.trim().lines().next().unwrap_or_default().trim();
|
||||||
|
if head.is_empty() {
|
||||||
|
fallback.to_string()
|
||||||
|
} else {
|
||||||
|
head.chars().take(80).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn repo_root() -> PathBuf {
|
||||||
|
if let Some(explicit) = env::var_os("CONSTANT_REPO_DIR") {
|
||||||
|
return PathBuf::from(explicit);
|
||||||
|
}
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn home_dir() -> Option<PathBuf> {
|
||||||
|
env::var_os("HOME").map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cache_root() -> PathBuf {
|
||||||
|
home_dir()
|
||||||
|
.map(|home| home.join(".cache/constant"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".cache/constant"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_root() -> PathBuf {
|
||||||
|
home_dir()
|
||||||
|
.map(|home| home.join(".config/constant"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".config/constant"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn data_root() -> PathBuf {
|
||||||
|
home_dir()
|
||||||
|
.map(|home| home.join(".local/share/constant"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(".local/share/constant"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn planner_dir() -> PathBuf {
|
||||||
|
cache_root().join("planner")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn missions_dir() -> PathBuf {
|
||||||
|
cache_root().join("missions")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chat_root() -> PathBuf {
|
||||||
|
cache_root().join("chat")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fleet_toml_path() -> PathBuf {
|
||||||
|
config_root().join("fleet.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn models_toml_path() -> PathBuf {
|
||||||
|
config_root().join("models.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn memory_toml_path() -> PathBuf {
|
||||||
|
config_root().join("memory.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fleet_json_path() -> PathBuf {
|
||||||
|
config_root().join("fleet.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn models_json_path() -> PathBuf {
|
||||||
|
config_root().join("models.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn memory_json_path() -> PathBuf {
|
||||||
|
config_root().join("memory.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fleet_yaml_path() -> PathBuf {
|
||||||
|
config_root().join("fleet.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn models_yaml_path() -> PathBuf {
|
||||||
|
config_root().join("models.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn memory_yaml_path() -> PathBuf {
|
||||||
|
config_root().join("memory.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn memory_store_path() -> PathBuf {
|
||||||
|
data_root().join("memory.sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn persona_path() -> PathBuf {
|
||||||
|
data_root().join("persona.md")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn indexes_dir() -> PathBuf {
|
||||||
|
data_root().join("indexes")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn memory_sources_dir() -> PathBuf {
|
||||||
|
data_root().join("sources")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mission_dir(mission_id: &str) -> PathBuf {
|
||||||
|
missions_dir().join(mission_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mission_file(mission_id: &str) -> PathBuf {
|
||||||
|
mission_dir(mission_id).join("mission.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mission_events_file(mission_id: &str) -> PathBuf {
|
||||||
|
mission_dir(mission_id).join("events.ndjson")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn mission_artifacts_dir(mission_id: &str) -> PathBuf {
|
||||||
|
mission_dir(mission_id).join("artifacts")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_runtime_dirs() -> Result<(), String> {
|
||||||
|
for path in [
|
||||||
|
cache_root(),
|
||||||
|
chat_root(),
|
||||||
|
config_root(),
|
||||||
|
data_root(),
|
||||||
|
missions_dir(),
|
||||||
|
indexes_dir(),
|
||||||
|
memory_sources_dir(),
|
||||||
|
planner_dir(),
|
||||||
|
] {
|
||||||
|
fs::create_dir_all(&path)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", path.display()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+281
@@ -0,0 +1,281 @@
|
|||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
use crate::config::ModelsConfig;
|
||||||
|
use crate::paths;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct MissionStep {
|
||||||
|
#[serde(default)]
|
||||||
|
pub step_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub prompt: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub machine: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub backend: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cli: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub agent: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub agent_role: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skill: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub skill_summary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attempt: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub depends_on: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artifact_refs: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub result_summary: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Mission {
|
||||||
|
pub mission_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub goal: String,
|
||||||
|
pub workspace: String,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default = "default_priority")]
|
||||||
|
pub priority: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub planner_model: String,
|
||||||
|
pub buddy_model: String,
|
||||||
|
pub verify_model: String,
|
||||||
|
#[serde(default = "default_owner")]
|
||||||
|
pub owner: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub routing_overrides: Map<String, Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub steps: Vec<MissionStep>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub artifacts: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub meta: Map<String, Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub planner_summary: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub buddy_review: Option<Value>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: Map<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_priority() -> String {
|
||||||
|
"normal".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_owner() -> String {
|
||||||
|
"Constant".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_utc() -> String {
|
||||||
|
let output = Command::new("date")
|
||||||
|
.args(["-u", "+%Y-%m-%dT%H:%M:%SZ"])
|
||||||
|
.output();
|
||||||
|
match output {
|
||||||
|
Ok(result) if result.status.success() => {
|
||||||
|
String::from_utf8_lossy(&result.stdout).trim().to_string()
|
||||||
|
}
|
||||||
|
_ => "1970-01-01T00:00:00Z".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_mission(
|
||||||
|
goal: &str,
|
||||||
|
workspace: &str,
|
||||||
|
routing_overrides: Option<Map<String, Value>>,
|
||||||
|
models: &ModelsConfig,
|
||||||
|
) -> Mission {
|
||||||
|
let mission_id = mission_id();
|
||||||
|
let timestamp = now_utc();
|
||||||
|
let title_line = goal.trim().lines().next().unwrap_or_default().trim();
|
||||||
|
let title = if title_line.is_empty() {
|
||||||
|
format!("mission-{mission_id}")
|
||||||
|
} else {
|
||||||
|
title_line.chars().take(80).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut meta = Map::new();
|
||||||
|
meta.insert("schema_version".to_string(), json!(1));
|
||||||
|
meta.insert("tool_version".to_string(), json!(env!("CARGO_PKG_VERSION")));
|
||||||
|
|
||||||
|
Mission {
|
||||||
|
mission_id,
|
||||||
|
title,
|
||||||
|
goal: goal.to_string(),
|
||||||
|
workspace: workspace.to_string(),
|
||||||
|
status: "draft".to_string(),
|
||||||
|
priority: default_priority(),
|
||||||
|
created_at: timestamp.clone(),
|
||||||
|
updated_at: timestamp,
|
||||||
|
planner_model: models.planner.model_id.clone(),
|
||||||
|
buddy_model: models.buddy.model_id.clone(),
|
||||||
|
verify_model: models.verify.model_id.clone(),
|
||||||
|
owner: default_owner(),
|
||||||
|
routing_overrides: routing_overrides.unwrap_or_default(),
|
||||||
|
steps: Vec::new(),
|
||||||
|
artifacts: Vec::new(),
|
||||||
|
meta,
|
||||||
|
planner_summary: None,
|
||||||
|
buddy_review: None,
|
||||||
|
extra: Map::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mission_id() -> String {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|value| value.as_nanos())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let pid = std::process::id() as u128;
|
||||||
|
format!("{:012x}", (nanos ^ pid) & 0xffff_ffff_ffff)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_mission(mission: &mut Mission) -> Result<(), String> {
|
||||||
|
paths::ensure_runtime_dirs()?;
|
||||||
|
mission.updated_at = now_utc();
|
||||||
|
let path = paths::mission_file(&mission.mission_id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let text = serde_json::to_string_pretty(mission)
|
||||||
|
.map_err(|err| format!("cannot encode mission {}: {err}", mission.mission_id))?;
|
||||||
|
fs::write(&path, format!("{text}\n"))
|
||||||
|
.map_err(|err| format!("cannot write {}: {err}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_mission(mission_id: &str) -> Result<Mission, String> {
|
||||||
|
let path = paths::mission_file(mission_id);
|
||||||
|
let text = fs::read_to_string(&path)
|
||||||
|
.map_err(|err| format!("cannot read {}: {err}", path.display()))?;
|
||||||
|
serde_json::from_str(&text).map_err(|err| format!("cannot parse {}: {err}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_missions() -> Result<Vec<Mission>, String> {
|
||||||
|
paths::ensure_runtime_dirs()?;
|
||||||
|
let mut paths_list = Vec::new();
|
||||||
|
for entry in fs::read_dir(paths::missions_dir())
|
||||||
|
.map_err(|err| format!("cannot read {}: {err}", paths::missions_dir().display()))?
|
||||||
|
{
|
||||||
|
let entry = entry.map_err(|err| format!("cannot iterate missions dir: {err}"))?;
|
||||||
|
let mission_file = entry.path().join("mission.json");
|
||||||
|
if mission_file.exists() {
|
||||||
|
paths_list.push(mission_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths_list.sort();
|
||||||
|
|
||||||
|
let mut missions = Vec::new();
|
||||||
|
for path in paths_list {
|
||||||
|
let text = fs::read_to_string(&path)
|
||||||
|
.map_err(|err| format!("cannot read {}: {err}", path.display()))?;
|
||||||
|
let mission: Mission = serde_json::from_str(&text)
|
||||||
|
.map_err(|err| format!("cannot parse {}: {err}", path.display()))?;
|
||||||
|
missions.push(mission);
|
||||||
|
}
|
||||||
|
Ok(missions)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn append_event(mission_id: &str, event_type: &str, payload: Value) -> Result<(), String> {
|
||||||
|
let path = paths::mission_events_file(mission_id);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let event = json!({
|
||||||
|
"timestamp": now_utc(),
|
||||||
|
"type": event_type,
|
||||||
|
"payload": payload,
|
||||||
|
});
|
||||||
|
append_json_line(&path, &event)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn write_artifact(mission_id: &str, name: &str, payload: &Value) -> Result<String, String> {
|
||||||
|
let artifact_dir = paths::mission_artifacts_dir(mission_id);
|
||||||
|
fs::create_dir_all(&artifact_dir)
|
||||||
|
.map_err(|err| format!("cannot create {}: {err}", artifact_dir.display()))?;
|
||||||
|
let path = artifact_dir.join(name);
|
||||||
|
let text = serde_json::to_string_pretty(payload)
|
||||||
|
.map_err(|err| format!("cannot encode artifact {name}: {err}"))?;
|
||||||
|
fs::write(&path, format!("{text}\n"))
|
||||||
|
.map_err(|err| format!("cannot write {}: {err}", path.display()))?;
|
||||||
|
Ok(path.display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mission_summary_value(mission: &Mission) -> Value {
|
||||||
|
json!({
|
||||||
|
"mission_id": mission.mission_id,
|
||||||
|
"title": mission.title,
|
||||||
|
"status": mission.status,
|
||||||
|
"workspace": mission.workspace,
|
||||||
|
"steps": mission.steps.iter().map(step_summary_value).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn step_summary_value(step: &MissionStep) -> Value {
|
||||||
|
json!({
|
||||||
|
"step_id": step.step_id,
|
||||||
|
"status": step.status,
|
||||||
|
"machine": step.machine,
|
||||||
|
"backend": step.backend,
|
||||||
|
"cli": step.cli,
|
||||||
|
"agent": step.agent,
|
||||||
|
"agent_role": step.agent_role,
|
||||||
|
"skill": step.skill,
|
||||||
|
"skill_summary": step.skill_summary,
|
||||||
|
"attempt": step.attempt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn first_active_step(mission: &Mission) -> Option<&MissionStep> {
|
||||||
|
mission
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.find(|step| !matches!(step.status.as_str(), "done" | "failed" | "needs_human"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn first_active_step_mut(mission: &mut Mission) -> Option<&mut MissionStep> {
|
||||||
|
mission
|
||||||
|
.steps
|
||||||
|
.iter_mut()
|
||||||
|
.find(|step| !matches!(step.status.as_str(), "done" | "failed" | "needs_human"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mission_events_text(mission_id: &str) -> Result<String, String> {
|
||||||
|
let path = paths::mission_events_file(mission_id);
|
||||||
|
fs::read_to_string(&path).map_err(|err| format!("cannot read {}: {err}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_json_line(path: &std::path::Path, value: &Value) -> Result<(), String> {
|
||||||
|
let mut handle = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)
|
||||||
|
.map_err(|err| format!("cannot open {}: {err}", path.display()))?;
|
||||||
|
let line =
|
||||||
|
serde_json::to_string(value).map_err(|err| format!("cannot encode JSON line: {err}"))?;
|
||||||
|
handle
|
||||||
|
.write_all(format!("{line}\n").as_bytes())
|
||||||
|
.map_err(|err| format!("cannot append {}: {err}", path.display()))
|
||||||
|
}
|
||||||
+490
@@ -0,0 +1,490 @@
|
|||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||||
|
use crossterm::execute;
|
||||||
|
use crossterm::terminal::{
|
||||||
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||||
|
};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::cockpit::{ROLES, capture_pane, focus_machine, restart_pane, runtime_status};
|
||||||
|
use crate::state::{list_missions, mission_summary_value};
|
||||||
|
|
||||||
|
pub enum TuiAction {
|
||||||
|
Exit,
|
||||||
|
OpenCockpit,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct App {
|
||||||
|
workspace: String,
|
||||||
|
local_session: String,
|
||||||
|
machine_session: String,
|
||||||
|
selected_mission: usize,
|
||||||
|
selected_machine: usize,
|
||||||
|
selected_role: usize,
|
||||||
|
missions: Vec<Value>,
|
||||||
|
runtime: Option<crate::cockpit::FleetRuntimeStatus>,
|
||||||
|
flash: String,
|
||||||
|
flash_until: Instant,
|
||||||
|
capture: Option<String>,
|
||||||
|
capture_title: String,
|
||||||
|
last_refresh: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEXAPUS_FRAMES: &[&[&str]] = &[
|
||||||
|
&[
|
||||||
|
" .-====-.",
|
||||||
|
" .-' .--. `-.",
|
||||||
|
" / ( oo ) \\",
|
||||||
|
" | \\/\\/ |",
|
||||||
|
" | .-____-. |",
|
||||||
|
" \\__/_/ || \\_\\__/",
|
||||||
|
" /_ /||\\ _\\",
|
||||||
|
" <_____/ || \\_____>",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
" .-====-.",
|
||||||
|
" .-' .--. `-.",
|
||||||
|
" / ( -- ) \\",
|
||||||
|
" | \\/\\/ |",
|
||||||
|
" | .-____-. |",
|
||||||
|
" _/\\_/ / || \\ \\_/\\_",
|
||||||
|
"/_ _\\/ || \\/_ _\\",
|
||||||
|
" <_____/||\\_____>",
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
" .-====-.",
|
||||||
|
" .-' .--. `-.",
|
||||||
|
" / ( xx ) \\",
|
||||||
|
" | \\/\\/ |",
|
||||||
|
" | .-____-. |",
|
||||||
|
" \\__/__/||\\__\\__/",
|
||||||
|
" _\\_ /||\\ _/_",
|
||||||
|
" <_____/ || \\_____>",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
fn new(workspace: String, local_session: String, machine_session: String) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace,
|
||||||
|
local_session,
|
||||||
|
machine_session,
|
||||||
|
selected_mission: 0,
|
||||||
|
selected_machine: 0,
|
||||||
|
selected_role: 1,
|
||||||
|
missions: Vec::new(),
|
||||||
|
runtime: None,
|
||||||
|
flash: String::new(),
|
||||||
|
flash_until: Instant::now(),
|
||||||
|
capture: None,
|
||||||
|
capture_title: String::new(),
|
||||||
|
last_refresh: Instant::now() - Duration::from_secs(10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(&mut self) {
|
||||||
|
if self.last_refresh.elapsed() < Duration::from_millis(700) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.missions = list_missions()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|mission| mission_summary_value(&mission))
|
||||||
|
.collect();
|
||||||
|
self.runtime = runtime_status(&self.local_session, &self.machine_session).ok();
|
||||||
|
if self.selected_mission >= self.missions.len() {
|
||||||
|
self.selected_mission = self.missions.len().saturating_sub(1);
|
||||||
|
}
|
||||||
|
if let Some(runtime) = &self.runtime {
|
||||||
|
if self.selected_machine >= runtime.machines.len() {
|
||||||
|
self.selected_machine = runtime.machines.len().saturating_sub(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.selected_machine = 0;
|
||||||
|
}
|
||||||
|
self.last_refresh = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flash(&mut self, message: impl Into<String>) {
|
||||||
|
self.flash = message.into();
|
||||||
|
self.flash_until = Instant::now() + Duration::from_secs(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_flash_if_needed(&mut self) {
|
||||||
|
if Instant::now() >= self.flash_until {
|
||||||
|
self.flash.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_tui(
|
||||||
|
workspace: String,
|
||||||
|
local_session: String,
|
||||||
|
machine_session: String,
|
||||||
|
) -> Result<TuiAction, String> {
|
||||||
|
enable_raw_mode().map_err(|err| format!("cannot enable raw mode: {err}"))?;
|
||||||
|
let mut stdout = std::io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)
|
||||||
|
.map_err(|err| format!("cannot enter alternate screen: {err}"))?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal =
|
||||||
|
Terminal::new(backend).map_err(|err| format!("cannot create terminal: {err}"))?;
|
||||||
|
|
||||||
|
let mut app = App::new(workspace, local_session, machine_session);
|
||||||
|
let result = run_loop(&mut terminal, &mut app);
|
||||||
|
|
||||||
|
disable_raw_mode().ok();
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
|
||||||
|
terminal.show_cursor().ok();
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_loop(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||||
|
app: &mut App,
|
||||||
|
) -> Result<TuiAction, String> {
|
||||||
|
loop {
|
||||||
|
app.refresh();
|
||||||
|
app.clear_flash_if_needed();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| draw(frame, app))
|
||||||
|
.map_err(|err| format!("cannot draw TUI: {err}"))?;
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(120))
|
||||||
|
.map_err(|err| format!("event poll failed: {err}"))?
|
||||||
|
{
|
||||||
|
let Event::Key(key) =
|
||||||
|
event::read().map_err(|err| format!("event read failed: {err}"))?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') => return Ok(TuiAction::Exit),
|
||||||
|
KeyCode::Char('j') => {
|
||||||
|
if app.selected_mission + 1 < app.missions.len() {
|
||||||
|
app.selected_mission += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('k') => {
|
||||||
|
app.selected_mission = app.selected_mission.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Char(']') => {
|
||||||
|
if let Some(runtime) = &app.runtime {
|
||||||
|
if app.selected_machine + 1 < runtime.machines.len() {
|
||||||
|
app.selected_machine += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('[') => {
|
||||||
|
app.selected_machine = app.selected_machine.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Char('1') => app.selected_role = 0,
|
||||||
|
KeyCode::Char('2') => app.selected_role = 1,
|
||||||
|
KeyCode::Char('3') => app.selected_role = 2,
|
||||||
|
KeyCode::Char('4') => app.selected_role = 3,
|
||||||
|
KeyCode::Char('o') => {
|
||||||
|
if let Some(machine) = selected_machine(app) {
|
||||||
|
let role = ROLES[app.selected_role];
|
||||||
|
match focus_machine(
|
||||||
|
&machine.label,
|
||||||
|
Some(role),
|
||||||
|
&app.local_session,
|
||||||
|
&app.machine_session,
|
||||||
|
) {
|
||||||
|
Ok(_) => app.flash(format!("focused {}:{}", machine.label, role)),
|
||||||
|
Err(err) => app.flash(format!("focus failed: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => {
|
||||||
|
if let Some(machine) = selected_machine(app) {
|
||||||
|
let role = ROLES[app.selected_role];
|
||||||
|
match restart_pane(&machine.label, role, &app.machine_session) {
|
||||||
|
Ok(result) if result.returncode == 0 => {
|
||||||
|
app.flash(format!("restart sent to {}:{}", machine.label, role))
|
||||||
|
}
|
||||||
|
Ok(result) => app
|
||||||
|
.flash(format!("restart failed: {}", compact_err(&result.stderr))),
|
||||||
|
Err(err) => app.flash(format!("restart failed: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('x') => {
|
||||||
|
if app.capture.is_some() {
|
||||||
|
app.capture = None;
|
||||||
|
app.capture_title.clear();
|
||||||
|
} else if let Some(machine) = selected_machine(app) {
|
||||||
|
let machine_label = machine.label.clone();
|
||||||
|
let role = ROLES[app.selected_role];
|
||||||
|
match capture_pane(&machine_label, role, 80, &app.machine_session) {
|
||||||
|
Ok(result) if result.returncode == 0 => {
|
||||||
|
app.capture = Some(result.stdout);
|
||||||
|
app.capture_title = format!("{}:{}", machine_label, role);
|
||||||
|
}
|
||||||
|
Ok(result) => app
|
||||||
|
.flash(format!("capture failed: {}", compact_err(&result.stderr))),
|
||||||
|
Err(err) => app.flash(format!("capture failed: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.capture = None;
|
||||||
|
app.capture_title.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Char('z') => return Ok(TuiAction::OpenCockpit),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_machine(app: &App) -> Option<&crate::cockpit::MachineRuntimeStatus> {
|
||||||
|
app.runtime.as_ref()?.machines.get(app.selected_machine)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
draw_header(frame, chunks[0], app);
|
||||||
|
|
||||||
|
let main = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(28),
|
||||||
|
Constraint::Percentage(48),
|
||||||
|
Constraint::Percentage(24),
|
||||||
|
])
|
||||||
|
.split(chunks[1]);
|
||||||
|
|
||||||
|
draw_missions(frame, main[0], app);
|
||||||
|
if app.capture.is_some() {
|
||||||
|
draw_capture(frame, main[1], app);
|
||||||
|
} else {
|
||||||
|
draw_runtime(frame, main[1], app);
|
||||||
|
}
|
||||||
|
draw_hexapus(frame, main[2], app);
|
||||||
|
draw_footer(frame, chunks[2], app);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_header(frame: &mut ratatui::Frame, area: Rect, app: &App) {
|
||||||
|
let machine_text = if let Some(machine) = selected_machine(app) {
|
||||||
|
format!("{} / {}", machine.label, ROLES[app.selected_role])
|
||||||
|
} else {
|
||||||
|
"no machine".to_string()
|
||||||
|
};
|
||||||
|
let text = Paragraph::new(vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" CONSTANT ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Black)
|
||||||
|
.bg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(app.workspace.as_str(), Style::default().fg(Color::Yellow)),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::raw("fleet="),
|
||||||
|
Span::styled(
|
||||||
|
app.local_session.as_str(),
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
),
|
||||||
|
Span::raw(" machine="),
|
||||||
|
Span::styled(machine_text, Style::default().fg(Color::Magenta)),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Rust Cockpit"));
|
||||||
|
frame.render_widget(text, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_missions(frame: &mut ratatui::Frame, area: Rect, app: &App) {
|
||||||
|
let items = app
|
||||||
|
.missions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, mission)| {
|
||||||
|
let selected = index == app.selected_mission;
|
||||||
|
let title = mission
|
||||||
|
.get("title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("mission");
|
||||||
|
let status = mission
|
||||||
|
.get("status")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let style = if selected {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
};
|
||||||
|
ListItem::new(Line::from(vec![
|
||||||
|
Span::styled(format!("{title} "), style),
|
||||||
|
Span::styled(format!("[{status}]"), style.fg(Color::Cyan)),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let list = List::new(items).block(Block::default().borders(Borders::ALL).title("Missions"));
|
||||||
|
frame.render_widget(list, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_runtime(frame: &mut ratatui::Frame, area: Rect, app: &App) {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
if let Some(runtime) = &app.runtime {
|
||||||
|
for (index, machine) in runtime.machines.iter().enumerate() {
|
||||||
|
let selected = index == app.selected_machine;
|
||||||
|
let prefix = if selected { ">" } else { " " };
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(
|
||||||
|
"{prefix} {} [{}]",
|
||||||
|
machine.label,
|
||||||
|
if machine.session_exists { "up" } else { "down" }
|
||||||
|
),
|
||||||
|
if selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
},
|
||||||
|
)));
|
||||||
|
for (role_index, role) in ROLES.iter().enumerate() {
|
||||||
|
let marker = if selected && role_index == app.selected_role {
|
||||||
|
"*"
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
let pane = machine.roles.get(*role).and_then(|pane| pane.as_ref());
|
||||||
|
let status = match pane {
|
||||||
|
Some(pane) if pane.dead => "dead",
|
||||||
|
Some(_) => "live",
|
||||||
|
None => "missing",
|
||||||
|
};
|
||||||
|
lines.push(Line::from(format!(" {marker} {} {}", role, status)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from("No runtime status."));
|
||||||
|
}
|
||||||
|
let paragraph = Paragraph::new(lines)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Runtime"))
|
||||||
|
.wrap(Wrap { trim: true });
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_capture(frame: &mut ratatui::Frame, area: Rect, app: &App) {
|
||||||
|
let capture = app.capture.as_deref().unwrap_or("");
|
||||||
|
let paragraph = Paragraph::new(capture)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(format!("Capture {}", app.capture_title)),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_hexapus(frame: &mut ratatui::Frame, area: Rect, app: &App) {
|
||||||
|
let tick = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|value| value.as_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let frame_index = (tick / 220 % HEXAPUS_FRAMES.len() as u128) as usize;
|
||||||
|
let frame_lines = HEXAPUS_FRAMES[frame_index];
|
||||||
|
let mut lines = frame_lines
|
||||||
|
.iter()
|
||||||
|
.map(|line| Line::from(Span::styled(*line, Style::default().fg(Color::Cyan))))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
if let Some(machine) = selected_machine(app) {
|
||||||
|
lines.push(Line::from(format!("selected {}", machine.label)));
|
||||||
|
lines.push(Line::from(format!(
|
||||||
|
"pane {}",
|
||||||
|
ROLES[app.selected_role]
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(format!(
|
||||||
|
"focused {}",
|
||||||
|
app.runtime
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|runtime| runtime.focused_machine.clone())
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let paragraph = Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title("Hexapus Buddy"),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_footer(frame: &mut ratatui::Frame, area: Rect, app: &App) {
|
||||||
|
let message = if app.flash.is_empty() {
|
||||||
|
"j/k mission | [/] machine | 1..4 pane | o focus | x capture | r restart | z cockpit | q quit".to_string()
|
||||||
|
} else {
|
||||||
|
app.flash.clone()
|
||||||
|
};
|
||||||
|
let paragraph =
|
||||||
|
Paragraph::new(message).block(Block::default().borders(Borders::ALL).title("Keys"));
|
||||||
|
frame.render_widget(paragraph, area);
|
||||||
|
|
||||||
|
if let Some(capture) = &app.capture {
|
||||||
|
let popup = centered_rect(80, 70, area);
|
||||||
|
frame.render_widget(Clear, popup);
|
||||||
|
let block = Paragraph::new(capture.as_str())
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title("Capture view (Esc/x close)"),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(block, popup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||||
|
let popup_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
Constraint::Percentage(percent_y),
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
])
|
||||||
|
.split(r);
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
Constraint::Percentage(percent_x),
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
])
|
||||||
|
.split(popup_layout[1])[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_err(text: &str) -> String {
|
||||||
|
text.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| !line.is_empty())
|
||||||
|
.unwrap_or("no details")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user