Refine Constant cockpit and Rust runtime

This commit is contained in:
L'électron rare
2026-04-06 07:20:14 +02:00
parent c8ad5f3e28
commit 6689352a29
36 changed files with 9662 additions and 308 deletions
+1
View File
@@ -19,3 +19,4 @@ output/
codex-profiles/ codex-profiles/
profiles/ profiles/
state/ state/
target/
Generated
+727
View File
@@ -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
View File
@@ -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"
+104 -16
View File
@@ -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
+482
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+18
View File
@@ -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
View File
@@ -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")
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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"]
+133
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+38 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
+19 -1
View File
@@ -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
+25 -2
View File
@@ -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
+13 -2
View File
@@ -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)
+15 -1
View File
@@ -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
} }
+41
View File
@@ -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": [],
}))
}
+558
View File
@@ -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
View File
@@ -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, &quoted), 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
View File
@@ -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
View File
@@ -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)
}
+56
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1302
View File
File diff suppressed because it is too large Load Diff
+228
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}