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/
profiles/
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:
- a Rust front-controller for the canonical `constant` entrypoint
- a 4-pane host-local `tmux` session per machine
- a fleet cockpit with one window per machine plus a central `Constant` window
- `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-*`.
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
Most AI tooling is designed like a vending machine:
@@ -117,6 +136,49 @@ That gives you:
- cockpit handoff
- 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
The memory layer is being built to support:
@@ -204,7 +266,30 @@ If `Constant` is on your `PATH`, you can also just run:
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
@@ -216,7 +301,7 @@ In interactive mode, `Constant` with no arguments now opens the TUI by default.
- validates candidates with a short SSH probe
- lets you select targets interactively
- 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
Examples:
@@ -237,7 +322,7 @@ You can pass raw SSH seeds such as:
- `root@192.168.0.119`
- `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`:
@@ -253,24 +338,25 @@ Constant fleet configure \
### TUI keys
`Constant tui` gives you:
`Constant tui` now gives you:
- a mission deck
- a mission board
- a runtime view of machine windows and pane states
- a chat-first central conversation surface
- a thread sidebar
- 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 bottom timeline mixed with memory echoes
- a bottom status / key strip
Useful keys:
- `j` / `k`: move between missions
- `f`: toggle `chat-focus` / `cockpit-detail`
- `[` / `]`: move between machines in the runtime view
- `1`..`4`: focus `claude`, `codex`, `copilot`, `vibe` on the selected machine
- `o`: jump to the selected machine window
- `1`..`4`: select `claude`, `codex`, `copilot`, `vibe` on the selected machine
- `o`: focus the selected machine + pane in the real cockpit
- `r`: restart the selected pane
- `x`: capture a short preview of the selected pane
- `e`: rebuild memory for the current workspace
- `s`: summarize the selected mission into durable memory
- `x`: open a capture view of the selected pane
- `z`: open the full fleet cockpit
- `q`: quit the TUI
@@ -301,11 +387,11 @@ Public example config:
```bash
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.
The shell launchers also read `fleet.json` now, so fleet tabs, install/check, and bridge helpers stay aligned with the same config.
Legacy `~/.config/constant/fleet.json` and `fleet.yaml` are still read for compatibility, but the native format is now TOML.
The shell launchers read both `fleet.toml` and `fleet.json`, so fleet tabs, install/check, and bridge helpers stay aligned during the migration.
## Messaging
@@ -383,7 +469,9 @@ Planned layers for the public `Constant` repo:
```text
constant/ Python orchestration core
src/ Rust CLI, missions, cockpit, and native TUI
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-deploy.sh discovery + selection + fleet deployment CLI
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 json
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
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 .daemon import daemon_status, request as daemon_request, serve_foreground, start_background, stop_background
from .executors import bridge_sync, execute_step, fleet_check
from .memory import (
enroll_workspace,
instruction_skill_sources,
list_decisions,
memory_status,
persona_markdown,
@@ -56,6 +67,96 @@ def _command_exists(binary: str) -> bool:
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:
try:
return rebuild_workspace_memory(workspace, enroll=True)
@@ -99,6 +200,9 @@ def _mission_summary(mission: dict[str, Any]) -> dict[str, Any]:
"backend": step["backend"],
"cli": step["cli"],
"agent": step["agent"],
"agent_role": step.get("agent_role"),
"skill": step.get("skill"),
"skill_summary": step.get("skill_summary"),
"attempt": step["attempt"],
}
for step in mission["steps"]
@@ -133,6 +237,7 @@ def cmd_doctor(args: argparse.Namespace) -> int:
"memory": memory_status(),
"fleet": [{"label": entry["label"], "target": entry["target"]} for entry in fleet["machines"]],
"cockpit": cockpit,
"wrapper": _wrapper_status(),
}
_print(report, args.json)
return 0
@@ -178,6 +283,24 @@ def cmd_models_status(args: argparse.Namespace) -> int:
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:
payload = fleet_check()
_print(payload, args.json)
@@ -491,14 +614,38 @@ def cmd_delegate(args: argparse.Namespace) -> int:
step["machine"] = args.machine
if args.backend:
step["backend"] = args.backend
if args.cli:
step["cli"] = args.cli
if args.agent:
step["agent"] = args.agent
if args.skill or args.agent or args.cli:
resolved_cli = args.cli
resolved_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"
mission["status"] = "planned"
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)
return 0
@@ -557,7 +704,7 @@ def cmd_mission_summarize(args: argparse.Namespace) -> int:
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__)
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.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_sub = fleet.add_subparsers(dest="fleet_command", required=True)
fleet_status_cmd = fleet_sub.add_parser("status")
@@ -715,6 +872,7 @@ def build_parser() -> argparse.ArgumentParser:
delegate.add_argument("--backend")
delegate.add_argument("--cli")
delegate.add_argument("--agent")
delegate.add_argument("--skill")
delegate.add_argument("--json", action="store_true")
delegate.set_defaults(func=cmd_delegate)
@@ -773,7 +931,7 @@ def main(argv: list[str] | None = None) -> int:
argv = sys.argv[1:]
if not argv:
if sys.stdin.isatty() and sys.stdout.isatty():
argv = ["tui", "--workspace", os.getcwd()]
argv = ["cockpit", "open", "--workspace", os.getcwd()]
else:
argv = ["doctor"]
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]:
fleet = load_fleet_config()
local_tmux = _run(["tmux", "list-windows", "-t", local_session, "-F", "#{window_name}"])
fleet_windows = local_tmux["stdout"].splitlines() if local_tmux["returncode"] == 0 else []
local_tmux = _run(["tmux", "list-windows", "-t", local_session, "-F", "#{window_name}\t#{window_active}"])
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"]]
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 {
"local_session": local_session,
"machine_session": machine_session,
"fleet_session_exists": local_tmux["returncode"] == 0,
"fleet_windows": fleet_windows,
"focused_machine": focused_machine,
"focused_role": focused_role,
"machines": machines,
"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
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]:
command_args = _machine_command_args(machine, args)
target = machine["target"]
local_names = {socket.gethostname(), socket.getfqdn(), socket.gethostname().split(".")[0], "local", "localhost", "127.0.0.1", "::1"}
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))
+18
View File
@@ -76,6 +76,15 @@ class _Handler(socketserver.StreamRequestHandler):
payload = self.engine.verify_step(request["mission"], request["step"], request["execution"])
elif op == "buddy":
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:
raise KeyError(f"Unsupported operation: {op}")
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["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}")
+51 -3
View File
@@ -256,8 +256,11 @@ def _detect_repo_root(workspace: Path) -> Path:
def _sanitize_fts_query(query: str) -> str:
tokens = re.findall(r"[A-Za-z0-9_.:/-]+", query.lower())
return " ".join(tokens[:12]) or "constant"
raw_tokens = re.findall(r"[A-Za-z0-9_]+", query.lower())
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]:
@@ -820,7 +823,11 @@ def search_memory(query: str, workspace: str | None = None, limit: int | None =
params.append(workspace_path)
chunk_sql += " order by rank limit ?"
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(
{
"kind": "repo",
@@ -918,6 +925,47 @@ def search_memory(query: str, workspace: str | None = None, limit: int | None =
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:
if persona_path().exists():
return persona_path().read_text(encoding="utf-8")
+4
View File
@@ -31,6 +31,10 @@ def missions_dir() -> Path:
return cache_root() / "missions"
def chat_root() -> Path:
return cache_root() / "chat"
def fleet_config_path() -> Path:
return config_root() / "fleet.json"
+353 -21
View File
@@ -3,12 +3,27 @@ from __future__ import annotations
import importlib.util
import json
import re
import subprocess
import sys
import threading
from dataclasses import dataclass
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
CHAT_ROLES = ("claude", "codex", "copilot", "vibe")
def _fleet_labels() -> dict[str, str]:
fleet = load_fleet_config()
@@ -47,9 +62,72 @@ def _lower(value: str) -> str:
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()
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")):
return labels["edge_a"]
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:
skill = match_skill(goal)
if skill.get("preferred_cli"):
return str(skill["preferred_cli"])
goal_l = _lower(goal)
if any(token in goal_l for token in ("brainstorm", "idea", "alternative", "explore", "compare")):
return "vibe"
@@ -81,21 +162,26 @@ def _route_backend(machine: str, cli: str, goal: str) -> str:
def _route_agent(cli: str) -> str:
return {
"claude": "planner",
"codex": "executor",
"vibe": "analyst",
"copilot": "assistant",
}.get(cli, "executor")
return agent_for_cli(cli)["id"]
def _heuristic_plan(goal: str, workspace: str, mission_id: str) -> dict[str, Any]:
machine = _route_machine(goal)
cli = _route_cli(goal)
backend = _route_backend(machine, cli, goal)
def _heuristic_plan(goal: str, workspace: str, mission_id: str, overrides: dict[str, Any] | None = None) -> dict[str, Any]:
overrides = overrides or {}
resolved = resolve_skill_and_agent(
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 {
"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": [
{
"step_id": "step-1",
@@ -105,7 +191,11 @@ def _heuristic_plan(goal: str, workspace: str, mission_id: str) -> dict[str, Any
"machine": machine,
"backend": backend,
"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",
"attempt": 0,
"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]:
title = mission["title"] if mission else "mission"
skill = match_skill(prompt)
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",
}
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
class ModelHealth:
role: str
@@ -207,7 +443,30 @@ class PlannerEngine:
self._models = load_models_config()
self._loaded: dict[str, tuple[Any, Any]] = {}
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]:
health = {}
@@ -222,8 +481,12 @@ class PlannerEngine:
).__dict__
return {
"mlx_python": self._mlx_python,
"mlx_probe": self._mlx_probe,
"models": health,
"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]:
@@ -285,7 +548,8 @@ class PlannerEngine:
).strip()
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)
fleet = load_fleet_config()
machine_labels = [machine["label"] for machine in fleet["machines"]]
@@ -302,9 +566,11 @@ class PlannerEngine:
"mission_id": mission["mission_id"],
"goal": mission["goal"],
"workspace": mission["workspace"],
"routing_overrides": overrides,
"machines": machine_labels,
"allowed_backends": ["omc", "cli-local", "cli-ssh", "cockpit"],
"allowed_clis": ["claude", "codex", "vibe"],
"skill_catalog": skill_catalog_brief(include_internal=True),
},
indent=2,
)
@@ -317,16 +583,30 @@ class PlannerEngine:
"steps": [],
}
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(
{
"step_id": step.get("step_id", f"step-{index}"),
"kind": step.get("kind", "task"),
"title": step.get("title", f"Step {index}"),
"prompt": step.get("prompt", mission["goal"]),
"machine": step.get("machine", _route_machine(mission["goal"])),
"backend": step.get("backend", _route_backend(step.get("machine", local_machine), step.get("cli", "codex"), mission["goal"])),
"cli": step.get("cli", _route_cli(mission["goal"])),
"agent": step.get("agent", _route_agent(step.get("cli", "codex"))),
"machine": resolved_machine,
"backend": step.get("backend", _route_backend(resolved_machine, resolved_cli, mission["goal"])),
"cli": resolved_cli,
"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",
"attempt": 0,
"depends_on": step.get("depends_on", []),
@@ -354,7 +634,9 @@ class PlannerEngine:
if not review.get("agrees", True):
if review.get("suggested_cli") and step["cli"] == "claude":
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"):
step["backend"] = review["suggested_backend"]
@@ -405,3 +687,53 @@ class PlannerEngine:
pass
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
import json
import hashlib
import uuid
from copy import deepcopy
from datetime import UTC, datetime
@@ -10,6 +11,7 @@ from typing import Any
from . import __version__
from .paths import (
cache_root,
chat_root,
config_root,
data_root,
fleet_config_path,
@@ -67,6 +69,7 @@ DEFAULT_FLEET: dict[str, Any] = {
DEFAULT_MODELS: dict[str, Any] = {
"version": 1,
"enable_mlx": "auto",
"planner": {
"role": "planner",
"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:
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)
@@ -150,7 +153,14 @@ def load_fleet_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]:
@@ -185,6 +195,63 @@ def mission_artifacts_dir(mission_id: str) -> Path:
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]:
mission_id = uuid.uuid4().hex[:12]
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)"
repo_dir="$(cd "$script_dir/.." && pwd -P)"
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:-}"
if [[ -z "$python_bin" ]]; then
@@ -21,4 +150,8 @@ if [[ -z "$python_bin" ]]; then
fi
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 "$@"
Executable → Regular
+32 -2
View File
@@ -8,6 +8,36 @@ while [[ -L "$script_source" ]]; do
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
done
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")}"
exec "$script_dir/zellij-ai-bootstrap.sh" "$@"
if [[ -n "$bootstrap_file" ]]; then
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"
done
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
source "$script_dir/constant-common.sh"
export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
exec "$script_dir/zellij-ai-copilot-pane.sh" "$@"
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
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
set -euo pipefail
script_source="${BASH_SOURCE[0]:-$0}"
while [[ -L "$script_source" ]]; do
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
script_source="$(readlink "$script_source")"
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
done
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
tabs_file="${ZELLIJ_AI_FLEET_TABS_FILE:-}"
tabs_created_file="${ZELLIJ_AI_FLEET_CREATED_FILE:-}"
current_label="${ZELLIJ_AI_FLEET_CURRENT_LABEL:-}"
current_wrapper="${ZELLIJ_AI_FLEET_CURRENT_WRAPPER:-}"
export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
exec "$script_dir/zellij-ai-fleet-bootstrap.sh" "$@"
if [[ -z "$current_wrapper" || ! -x "$current_wrapper" ]]; then
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() {
local inner
printf -v inner 'cd %q && export PATH=%q; exec %q tui --workspace %q --local-session %q --session %q' \
"$workspace" \
"$(zellij_ai_agent_path)" \
"$repo_dir/scripts/Constant" \
"$workspace" \
"$local_session" \
"$remote_session"
printf 'bash -lc %q' "$inner"
local cmd=(
env
"PATH=$(zellij_ai_agent_path)"
"CONSTANT_USE_PYTHON=1"
"$repo_dir/scripts/Constant"
tui
--workspace "$workspace"
--local-session "$local_session"
--session "$remote_session"
)
printf '%q ' "${cmd[@]}"
}
build_remote_window_command() {
local label="$1"
local target="$2"
local inner remote_script cmd cmd_joined
local remote_script cmd
remote_script="$repo_dir/scripts/constant-tmux-remote-window.sh"
cmd=(
@@ -77,12 +79,7 @@ build_remote_window_command() {
cmd+=(--recreate)
fi
printf -v cmd_joined '%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"
printf '%q ' "${cmd[@]}"
}
link_local_window() {
@@ -111,6 +108,91 @@ link_local_window() {
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
zellij_ai_require_command ssh
@@ -190,10 +272,7 @@ while [[ $# -gt 0 ]]; do
done
if $attach_only; then
if [[ -n "${TMUX:-}" ]]; then
exec tmux switch-client -t "$local_session"
fi
exec tmux attach-session -t "$local_session"
attach_fleet_session "$local_session"
fi
if [[ -z "$workspace" ]]; then
@@ -214,17 +293,18 @@ if $recreate && constant_tmux_session_exists "$local_session"; then
fi
if constant_tmux_session_exists "$local_session"; then
if [[ -n "${TMUX:-}" ]]; then
exec tmux switch-client -t "$local_session"
configure_fleet_session "$local_session"
ensure_fleet_windows
tmux select-window -t "${local_session}:Constant" >/dev/null 2>&1 || true
if $ensure_only; then
exit 0
fi
exec tmux attach-session -t "$local_session"
attach_fleet_session "$local_session"
fi
mkdir -p "$(zellij_ai_fleet_state_dir "$local_session")"
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
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
configure_fleet_session "$local_session"
for machine_spec in "${machines[@]}"; do
parsed_spec="$(zellij_ai_parse_machine_spec "$machine_spec")"
@@ -247,8 +327,4 @@ if $ensure_only; then
exit 0
fi
if [[ -n "${TMUX:-}" ]]; then
exec tmux switch-client -t "$local_session"
else
exec tmux attach-session -t "$local_session"
fi
attach_fleet_session "$local_session"
Executable → Regular
+44 -2
View File
@@ -8,6 +8,48 @@ while [[ -L "$script_source" ]]; do
[[ "$script_source" != /* ]] && script_source="$script_dir/$script_source"
done
script_dir="$(cd "$(dirname "$script_source")" && pwd -P)"
source "$script_dir/constant-common.sh"
export CONSTANT_SCRIPT_NAME="${CONSTANT_SCRIPT_NAME:-$(basename "$0")}"
exec "$script_dir/zellij-ai-vibe-pane.sh" "$@"
workspace="${ZELLIJ_AI_WORKSPACE:-$PWD}"
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)"
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}"
machine_name="${ZELLIJ_AI_MACHINE_NAME:-unknown}"
session_name="${ZELLIJ_AI_SESSION:-$(zellij_ai_default_session)}"
@@ -50,4 +61,11 @@ if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then
fi
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
}
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() {
printf 'Warning: %s\n' "$*" >&2
}
@@ -105,8 +118,18 @@ if [[ ! -f "$CODEX_HOME/auth.json" ]]; then
echo "No auth.json found for $label."
echo "Starting: codex login --device-auth"
echo
codex login --device-auth
if ! codex login --device-auth; then
echo
echo "Codex login did not complete successfully."
fi
echo
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
printf '%s\n' "$CONSTANT_FLEET_CONFIG"
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.yaml"
}
@@ -32,16 +33,26 @@ zellij_ai_fleet_config_query() {
[[ -n "$config_path" ]] || return 1
command -v python3 >/dev/null 2>&1 || return 1
python3 - "$config_path" "$expr" <<'PY'
python3 - "$config_path" "$expr" <<'PY'
import json
import sys
from pathlib import Path
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover
tomllib = None
path = Path(sys.argv[1])
expr = sys.argv[2]
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:
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_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)"
linux_id=""
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 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
print_status "$binary" "$(command -v "$binary")"
else
@@ -394,6 +406,8 @@ run_install() {
ensure_npm_global "@openai/codex" codex
ensure_npm_global "@github/copilot" copilot
ensure_uv_tool "mistral-vibe" vibe
ensure_constant_wrappers
refresh_path
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()
}