commit 95cb7f1878f3ea08e22d39f18fb2989ca4ecc1e4
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date: Sun, 31 May 2026 10:50:26 -0500
P0: workspace skeleton — daemon/TUI handshake over Unix socket
Four-crate workspace (hydra-ipc / hydra-core / hydrad / hydra) replacing
Loopback, structured like valentine/ivory-rust. NDJSON wire protocol with
zero macOS deps keeps the ratatui TUI off CoreAudio. Daemon binds the control
socket and answers Ping/GetState; TUI renders the Navi-themed shell with live
connection status. Theme is a swappable TOML (Navi default).
7/7 tests green; builds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
19 files changed, 1757 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+.DS_Store
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,901 @@
+# 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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+
+[[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.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
+[[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 = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[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 = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[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.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hydra"
+version = "0.1.0"
+dependencies = [
+ "crossterm",
+ "dirs",
+ "hydra-ipc",
+ "ratatui",
+ "serde",
+ "serde_json",
+ "toml",
+]
+
+[[package]]
+name = "hydra-core"
+version = "0.1.0"
+dependencies = [
+ "dirs",
+ "hydra-ipc",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "hydra-ipc"
+version = "0.1.0"
+dependencies = [
+ "dirs",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "hydrad"
+version = "0.1.0"
+dependencies = [
+ "hydra-core",
+ "hydra-ipc",
+ "serde_json",
+]
+
+[[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.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.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.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "libc",
+]
+
+[[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.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[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 = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom",
+ "libredox",
+ "thiserror",
+]
+
+[[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.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+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 = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[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.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[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"
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,14 @@
+[workspace]
+members = ["crates/hydra-ipc", "crates/hydra-core", "crates/hydrad", "crates/hydra"]
+resolver = "2"
+
+[workspace.package]
+version = "0.1.0"
+edition = "2021"
+license = "GPL-3.0-or-later"
+authors = ["Ganten"]
+
+[workspace.dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+dirs = "5"
diff --git a/README.md b/README.md
@@ -0,0 +1,41 @@
+# Hydra
+
+A terminal-native, functional replacement for [Loopback](https://rogueamoeba.com/loopback/) —
+create virtual audio devices, capture per-app audio, combine inputs, monitor, and map
+channels, all from a `ratatui` TUI. Navy/cyan/amber [Navi](https://github.com/ganten7/navi-palette)
+theme, swappable.
+
+> macOS only. Per-app capture needs **macOS 14.4+** (Core Audio process taps); the virtual
+> driver is a fork of [BlackHole](https://github.com/ExistentialAudio/BlackHole) (GPL-3.0).
+
+## Architecture
+
+| Piece | Crate / dir | Role |
+|-------|-------------|------|
+| Daemon | `crates/hydrad` | Owns **all** CoreAudio state; persists routing; the only process that links the engine. |
+| Engine | `crates/hydra-core` | Routing model, config, CoreAudio FFI (taps / aggregates / IOProcs). |
+| Wire protocol | `crates/hydra-ipc` | Shared NDJSON types over a Unix socket. **No macOS deps** — keeps the TUI off CoreAudio. |
+| TUI / query | `crates/hydra` | `ratatui` client + (P5) `hydra query --sketchybar`. |
+| Driver | `driver/` *(P2)* | Forked BlackHole publishing dynamic devices from a JSON manifest. |
+
+The daemon runs as a per-user **LaunchAgent** (GUI session — required for tap TCC consent).
+Clients talk to it over `~/Library/Application Support/hydra/hydrad.sock`.
+
+## Status
+
+**Phase 0 — skeleton.** Workspace builds; daemon answers `Ping`/`GetState`; TUI renders the
+Navi-themed shell and shows live connection status. CoreAudio engine + driver land in P1–P3.
+
+## Run it
+
+```sh
+cargo run -p hydrad # terminal 1: start the daemon
+cargo run -p hydra # terminal 2: the TUI (press q to quit, r to refresh)
+cargo test # unit tests (model + wire-format round-trips + theme parsing)
+```
+
+Swap the theme: copy `themes/navi.toml` to `~/Library/Application Support/hydra/theme.toml`.
+
+## License
+
+GPL-3.0-or-later (the BlackHole-derived driver makes this obligatory for the whole work).
diff --git a/crates/hydra-core/Cargo.toml b/crates/hydra-core/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "hydra-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+hydra-ipc = { path = "../hydra-ipc" }
+serde.workspace = true
+serde_json.workspace = true
+dirs.workspace = true
+
+# CoreAudio FFI (process taps, aggregate devices, IOProcs) lands in P1. The engine
+# modules will be gated behind #[cfg(target_os = "macos")] so the pure model/config
+# layer stays portable and unit-testable.
diff --git a/crates/hydra-core/src/lib.rs b/crates/hydra-core/src/lib.rs
@@ -0,0 +1,10 @@
+//! Hydra's routing core: the domain model, config persistence, and (from P1 onward)
+//! the CoreAudio engine. Only the daemon (`hydrad`) links this crate; the TUI talks to
+//! the daemon over [`hydra_ipc`] and never touches CoreAudio.
+
+pub mod model;
+
+pub use model::RoutingState;
+
+/// Daemon/engine version, surfaced to clients in snapshots.
+pub const VERSION: &str = env!("CARGO_PKG_VERSION");
diff --git a/crates/hydra-core/src/model.rs b/crates/hydra-core/src/model.rs
@@ -0,0 +1,80 @@
+//! The routing domain model. This is the authoritative in-memory state the daemon owns;
+//! [`RoutingState::snapshot`] projects it into the wire types in [`hydra_ipc`].
+//!
+//! P0 keeps this intentionally thin — sources, channel maps, monitors, and the live
+//! `MixSnapshot` arrive with the CoreAudio engine in P1–P3.
+
+use hydra_ipc::{DeviceSummary, RouteSummary, StateSnapshot};
+
+/// A virtual audio device Hydra publishes via the forked HAL driver.
+#[derive(Debug, Clone)]
+pub struct VirtualDevice {
+ pub uid: String,
+ pub name: String,
+ pub channels: u32,
+}
+
+/// A routing definition: sources mixed onto a target virtual device's channels.
+#[derive(Debug, Clone)]
+pub struct Route {
+ pub id: String,
+ pub target_device_uid: String,
+ /// Whether the route's CoreAudio objects are currently live.
+ pub active: bool,
+}
+
+/// The daemon's complete routing state.
+#[derive(Debug, Default)]
+pub struct RoutingState {
+ pub devices: Vec<VirtualDevice>,
+ pub routes: Vec<Route>,
+}
+
+impl RoutingState {
+ /// Project into a wire-safe snapshot for clients.
+ pub fn snapshot(&self) -> StateSnapshot {
+ StateSnapshot {
+ daemon_version: crate::VERSION.to_string(),
+ devices: self
+ .devices
+ .iter()
+ .map(|d| DeviceSummary { uid: d.uid.clone(), name: d.name.clone(), channels: d.channels })
+ .collect(),
+ routes: self
+ .routes
+ .iter()
+ .map(|r| RouteSummary {
+ id: r.id.clone(),
+ target: r.target_device_uid.clone(),
+ source_count: 0,
+ active: r.active,
+ })
+ .collect(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn empty_snapshot_carries_version() {
+ let snap = RoutingState::default().snapshot();
+ assert_eq!(snap.daemon_version, crate::VERSION);
+ assert!(snap.devices.is_empty());
+ assert!(snap.routes.is_empty());
+ }
+
+ #[test]
+ fn snapshot_projects_devices_and_routes() {
+ let state = RoutingState {
+ devices: vec![VirtualDevice { uid: "hydra:main".into(), name: "Main".into(), channels: 16 }],
+ routes: vec![Route { id: "r1".into(), target_device_uid: "hydra:main".into(), active: true }],
+ };
+ let snap = state.snapshot();
+ assert_eq!(snap.devices[0].channels, 16);
+ assert_eq!(snap.routes[0].target, "hydra:main");
+ assert!(snap.routes[0].active);
+ }
+}
diff --git a/crates/hydra-ipc/Cargo.toml b/crates/hydra-ipc/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "hydra-ipc"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[dependencies]
+serde.workspace = true
+serde_json.workspace = true
+dirs.workspace = true
diff --git a/crates/hydra-ipc/src/lib.rs b/crates/hydra-ipc/src/lib.rs
@@ -0,0 +1,152 @@
+//! Shared wire protocol for Hydra: the only crate both the daemon (`hydrad`) and the
+//! TUI/query binary (`hydra`) depend on. Deliberately has **zero** macOS / CoreAudio
+//! dependencies so the TUI can never accidentally link the audio engine.
+//!
+//! Transport is newline-delimited JSON (NDJSON) over a Unix-domain socket: one serde
+//! value per line, hand-debuggable with `nc`.
+
+use serde::{Deserialize, Serialize};
+use std::io::{self, BufRead, Write};
+use std::path::PathBuf;
+
+/// Bumped when the wire format changes incompatibly.
+pub const PROTOCOL_VERSION: u32 = 1;
+
+/// `~/Library/Application Support/hydra` on macOS. The daemon keeps its socket, config,
+/// and theme here. Falls back to `/tmp/hydra` if the data dir can't be resolved.
+pub fn runtime_dir() -> PathBuf {
+ let mut p = dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
+ p.push("hydra");
+ p
+}
+
+/// Path to the daemon's control socket.
+pub fn socket_path() -> PathBuf {
+ let mut p = runtime_dir();
+ p.push("hydrad.sock");
+ p
+}
+
+// ── Request / response ────────────────────────────────────────────────────────
+
+/// A request from a client (TUI or `hydra query`) to the daemon.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Command {
+ /// Liveness check.
+ Ping,
+ /// Fetch the current routing snapshot.
+ GetState,
+ /// Opt this connection into the server-push event stream (state deltas, meters).
+ Subscribe,
+ /// Ask the daemon to exit.
+ Shutdown,
+}
+
+/// The daemon's reply to a [`Command`].
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Response {
+ Pong { version: u32 },
+ State(StateSnapshot),
+ Ok,
+ Error(String),
+}
+
+/// Server-initiated push messages (only sent after [`Command::Subscribe`]).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Event {
+ StateChanged(StateSnapshot),
+}
+
+// ── Snapshot types ────────────────────────────────────────────────────────────
+
+/// A read-only view of the daemon's routing state, safe to render or print.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct StateSnapshot {
+ pub daemon_version: String,
+ pub devices: Vec<DeviceSummary>,
+ pub routes: Vec<RouteSummary>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DeviceSummary {
+ pub uid: String,
+ pub name: String,
+ pub channels: u32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RouteSummary {
+ pub id: String,
+ pub target: String,
+ pub source_count: usize,
+ pub active: bool,
+}
+
+// ── NDJSON framing ────────────────────────────────────────────────────────────
+
+fn invalid_data<E: std::fmt::Display>(e: E) -> io::Error {
+ io::Error::new(io::ErrorKind::InvalidData, e.to_string())
+}
+
+/// Serialize `msg` as a single JSON line and flush it to `w`.
+pub fn write_msg<W: Write, T: Serialize>(w: &mut W, msg: &T) -> io::Result<()> {
+ let mut line = serde_json::to_vec(msg).map_err(invalid_data)?;
+ line.push(b'\n');
+ w.write_all(&line)?;
+ w.flush()
+}
+
+/// Read one JSON line from `r`. Returns `Ok(None)` on clean EOF.
+pub fn read_msg<R: BufRead, T: for<'de> Deserialize<'de>>(r: &mut R) -> io::Result<Option<T>> {
+ let mut line = String::new();
+ if r.read_line(&mut line)? == 0 {
+ return Ok(None);
+ }
+ let trimmed = line.trim_end();
+ if trimmed.is_empty() {
+ return Ok(None);
+ }
+ serde_json::from_str(trimmed).map(Some).map_err(invalid_data)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::BufReader;
+
+ #[test]
+ fn command_round_trips() {
+ let mut buf = Vec::new();
+ write_msg(&mut buf, &Command::Ping).unwrap();
+ let mut r = BufReader::new(&buf[..]);
+ let got: Option<Command> = read_msg(&mut r).unwrap();
+ assert!(matches!(got, Some(Command::Ping)));
+ }
+
+ #[test]
+ fn snapshot_round_trips() {
+ let snap = StateSnapshot {
+ daemon_version: "0.1.0".into(),
+ devices: vec![DeviceSummary { uid: "hydra:main".into(), name: "Main".into(), channels: 16 }],
+ routes: vec![RouteSummary { id: "r1".into(), target: "hydra:main".into(), source_count: 2, active: true }],
+ };
+ let mut buf = Vec::new();
+ write_msg(&mut buf, &Response::State(snap)).unwrap();
+ let mut r = BufReader::new(&buf[..]);
+ let got: Option<Response> = read_msg(&mut r).unwrap();
+ match got {
+ Some(Response::State(s)) => {
+ assert_eq!(s.devices.len(), 1);
+ assert_eq!(s.routes[0].source_count, 2);
+ }
+ other => panic!("unexpected: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn eof_returns_none() {
+ let mut r = BufReader::new(&b""[..]);
+ let got: Option<Command> = read_msg(&mut r).unwrap();
+ assert!(got.is_none());
+ }
+}
diff --git a/crates/hydra/Cargo.toml b/crates/hydra/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "hydra"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[[bin]]
+name = "hydra"
+path = "src/main.rs"
+
+[dependencies]
+# The TUI depends on hydra-ipc ONLY — never hydra-core — so it can't link CoreAudio.
+hydra-ipc = { path = "../hydra-ipc" }
+ratatui = "0.29"
+crossterm = "0.28"
+serde.workspace = true
+serde_json.workspace = true
+dirs.workspace = true
+toml = "0.8"
diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs
@@ -0,0 +1,66 @@
+//! TUI application state and the actions that mutate it. Rendering lives in `ui.rs`.
+
+use hydra_ipc::{Command, Response, StateSnapshot};
+
+use crate::client::Client;
+
+/// Whether we're currently talking to the daemon.
+#[derive(Debug, Clone)]
+pub enum Connection {
+ Connected { protocol: u32 },
+ Disconnected { reason: String },
+}
+
+pub struct App {
+ pub connection: Connection,
+ pub snapshot: StateSnapshot,
+ pub status: String,
+ pub should_quit: bool,
+}
+
+impl App {
+ pub fn new() -> Self {
+ let mut app = App {
+ connection: Connection::Disconnected { reason: "not yet connected".into() },
+ snapshot: StateSnapshot::default(),
+ status: String::new(),
+ should_quit: false,
+ };
+ app.refresh();
+ app
+ }
+
+ /// (Re)connect to the daemon, ping it, and pull the latest snapshot.
+ pub fn refresh(&mut self) {
+ match Client::connect() {
+ Ok(client) => match client.request(Command::Ping) {
+ Ok(Response::Pong { version }) => {
+ self.connection = Connection::Connected { protocol: version };
+ self.pull_state(&client);
+ self.status = "connected".into();
+ }
+ Ok(other) => {
+ self.connection = Connection::Disconnected { reason: format!("unexpected reply: {other:?}") };
+ }
+ Err(e) => {
+ self.connection = Connection::Disconnected { reason: e.to_string() };
+ }
+ },
+ Err(e) => {
+ self.connection = Connection::Disconnected { reason: format!("daemon not reachable ({e})") };
+ }
+ }
+ }
+
+ fn pull_state(&mut self, client: &Client) {
+ match client.request(Command::GetState) {
+ Ok(Response::State(snap)) => self.snapshot = snap,
+ Ok(other) => self.status = format!("unexpected state reply: {other:?}"),
+ Err(e) => self.status = format!("GetState failed: {e}"),
+ }
+ }
+
+ pub fn quit(&mut self) {
+ self.should_quit = true;
+ }
+}
diff --git a/crates/hydra/src/client.rs b/crates/hydra/src/client.rs
@@ -0,0 +1,28 @@
+//! Thin blocking client to the `hydrad` control socket. P0 does simple one-shot
+//! request/response calls; the persistent subscribe/event stream arrives in P4.
+
+use std::io::{BufReader, BufWriter};
+use std::os::unix::net::UnixStream;
+
+use hydra_ipc::{read_msg, write_msg, Command, Response};
+
+pub struct Client {
+ stream: UnixStream,
+}
+
+impl Client {
+ /// Connect to the daemon's socket, or error if it isn't running.
+ pub fn connect() -> std::io::Result<Self> {
+ let stream = UnixStream::connect(hydra_ipc::socket_path())?;
+ Ok(Self { stream })
+ }
+
+ /// Send one command and read exactly one response.
+ pub fn request(&self, cmd: Command) -> std::io::Result<Response> {
+ let mut writer = BufWriter::new(self.stream.try_clone()?);
+ let mut reader = BufReader::new(self.stream.try_clone()?);
+ write_msg(&mut writer, &cmd)?;
+ read_msg::<_, Response>(&mut reader)?
+ .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "daemon closed connection"))
+ }
+}
diff --git a/crates/hydra/src/main.rs b/crates/hydra/src/main.rs
@@ -0,0 +1,66 @@
+//! `hydra` — the terminal UI (and, from P5, the `query` subcommand for SketchyBar).
+//! P0: connect to `hydrad`, show connection status + (empty) routing state, Navi-themed.
+
+mod app;
+mod client;
+mod theme;
+mod ui;
+
+use std::error::Error;
+use std::io::{self, Stdout};
+use std::time::Duration;
+
+use crossterm::{
+ event::{self, Event, KeyCode, KeyEventKind},
+ execute,
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use ratatui::{backend::CrosstermBackend, Terminal};
+
+use app::App;
+use theme::Theme;
+
+type Tui = Terminal<CrosstermBackend<Stdout>>;
+
+fn main() -> Result<(), Box<dyn Error>> {
+ let mut terminal = setup_terminal()?;
+ let theme = Theme::load();
+ let result = run(&mut terminal, theme);
+ restore_terminal(&mut terminal)?;
+ result
+}
+
+fn run(terminal: &mut Tui, theme: Theme) -> Result<(), Box<dyn Error>> {
+ let mut app = App::new();
+ while !app.should_quit {
+ terminal.draw(|f| ui::draw(f, &app, &theme))?;
+
+ // Poll so the UI stays responsive; no busy loop.
+ if event::poll(Duration::from_millis(250))? {
+ if let Event::Key(key) = event::read()? {
+ if key.kind == KeyEventKind::Press {
+ match key.code {
+ KeyCode::Char('q') | KeyCode::Esc => app.quit(),
+ KeyCode::Char('r') => app.refresh(),
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+fn setup_terminal() -> Result<Tui, Box<dyn Error>> {
+ enable_raw_mode()?;
+ let mut stdout = io::stdout();
+ execute!(stdout, EnterAlternateScreen)?;
+ Ok(Terminal::new(CrosstermBackend::new(stdout))?)
+}
+
+fn restore_terminal(terminal: &mut Tui) -> Result<(), Box<dyn Error>> {
+ disable_raw_mode()?;
+ execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
+ terminal.show_cursor()?;
+ Ok(())
+}
diff --git a/crates/hydra/src/theme.rs b/crates/hydra/src/theme.rs
@@ -0,0 +1,140 @@
+//! Swappable color theme. Defaults to the Navi palette but loads
+//! `~/Library/Application Support/hydra/theme.toml` (or a `--theme <path>`) if present,
+//! so the palette is configurable from day one and never hardcoded into widgets.
+
+use ratatui::style::Color;
+use serde::Deserialize;
+use std::path::{Path, PathBuf};
+
+/// Resolved colors used across the UI. Semantic roles, not raw palette names.
+#[derive(Debug, Clone)]
+pub struct Theme {
+ pub bg: Color,
+ pub bg_elevated: Color,
+ pub fg: Color,
+ pub fg_dim: Color,
+ /// Lone amber anchor — used sparingly for the primary accent.
+ pub accent: Color,
+ /// Ghost-light cyan — titles, cursor, active highlights.
+ pub ghost: Color,
+ pub border: Color,
+ pub success: Color,
+ pub warning: Color,
+ pub danger: Color,
+}
+
+impl Default for Theme {
+ /// The Navi palette (semantic subset). See ~/Dropbox/Projects/Libs/navi-palette.
+ fn default() -> Self {
+ Theme {
+ bg: rgb(0x10, 0x1e, 0x2e), // Deep Night Blue (main-bg)
+ bg_elevated: rgb(0x14, 0x2c, 0x4a), // Space Cadet (region-bg)
+ fg: rgb(0xe0, 0xf0, 0xff), // Alice Blue
+ fg_dim: rgb(0x78, 0x98, 0xb8), // Cadet Grey
+ accent: rgb(0xf0, 0x90, 0x30), // Amber (warm anchor)
+ ghost: rgb(0x40, 0xe8, 0xff), // Electric Cyan (ghost light)
+ border: rgb(0x26, 0x50, 0x80), // Payne's Grey
+ success: rgb(0x00, 0xe8, 0x98), // Caribbean Green
+ warning: rgb(0xf0, 0x90, 0x30), // Amber
+ danger: rgb(0xff, 0x60, 0x60), // Bittersweet
+ }
+ }
+}
+
+impl Theme {
+ /// Load from the standard config location, falling back to the Navi default.
+ pub fn load() -> Self {
+ Self::default_path()
+ .filter(|p| p.exists())
+ .and_then(|p| Self::from_file(&p).ok())
+ .unwrap_or_default()
+ }
+
+ /// `~/Library/Application Support/hydra/theme.toml`.
+ pub fn default_path() -> Option<PathBuf> {
+ dirs::data_dir().map(|d| d.join("hydra").join("theme.toml"))
+ }
+
+ /// Parse a theme file, using Navi defaults for any unspecified key.
+ pub fn from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
+ let raw = std::fs::read_to_string(path)?;
+ let file: ThemeFile = toml::from_str(&raw)?;
+ let mut t = Theme::default();
+ let p = file.palette;
+ apply(&mut t.bg, p.bg);
+ apply(&mut t.bg_elevated, p.bg_elevated);
+ apply(&mut t.fg, p.fg);
+ apply(&mut t.fg_dim, p.fg_dim);
+ apply(&mut t.accent, p.accent);
+ apply(&mut t.ghost, p.ghost);
+ apply(&mut t.border, p.border);
+ apply(&mut t.success, p.success);
+ apply(&mut t.warning, p.warning);
+ apply(&mut t.danger, p.danger);
+ Ok(t)
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct ThemeFile {
+ #[allow(dead_code)]
+ name: Option<String>,
+ #[serde(default)]
+ palette: Palette,
+}
+
+#[derive(Debug, Default, Deserialize)]
+struct Palette {
+ bg: Option<String>,
+ bg_elevated: Option<String>,
+ fg: Option<String>,
+ fg_dim: Option<String>,
+ accent: Option<String>,
+ ghost: Option<String>,
+ border: Option<String>,
+ success: Option<String>,
+ warning: Option<String>,
+ danger: Option<String>,
+}
+
+const fn rgb(r: u8, g: u8, b: u8) -> Color {
+ Color::Rgb(r, g, b)
+}
+
+/// Parse `#rrggbb` (with or without the leading `#`).
+fn parse_hex(s: &str) -> Option<Color> {
+ let s = s.trim().trim_start_matches('#');
+ if s.len() != 6 {
+ return None;
+ }
+ let r = u8::from_str_radix(&s[0..2], 16).ok()?;
+ let g = u8::from_str_radix(&s[2..4], 16).ok()?;
+ let b = u8::from_str_radix(&s[4..6], 16).ok()?;
+ Some(Color::Rgb(r, g, b))
+}
+
+/// Overwrite `slot` only if `value` is present and parses.
+fn apply(slot: &mut Color, value: Option<String>) {
+ if let Some(c) = value.as_deref().and_then(parse_hex) {
+ *slot = c;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_hex_with_and_without_hash() {
+ assert_eq!(parse_hex("#40e8ff"), Some(Color::Rgb(0x40, 0xe8, 0xff)));
+ assert_eq!(parse_hex("101e2e"), Some(Color::Rgb(0x10, 0x1e, 0x2e)));
+ assert_eq!(parse_hex("nope"), None);
+ }
+
+ #[test]
+ fn default_is_navi() {
+ let t = Theme::default();
+ assert_eq!(t.ghost, Color::Rgb(0x40, 0xe8, 0xff));
+ assert_eq!(t.accent, Color::Rgb(0xf0, 0x90, 0x30));
+ }
+}
diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs
@@ -0,0 +1,105 @@
+//! Ratatui rendering for the Hydra TUI. Pure view: reads [`App`] + [`Theme`], draws.
+
+use ratatui::{
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Paragraph},
+ Frame,
+};
+
+use crate::app::{App, Connection};
+use crate::theme::Theme;
+
+pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
+ let base = Style::default().bg(theme.bg).fg(theme.fg);
+ f.render_widget(Block::default().style(base), f.area());
+
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(4), // header
+ Constraint::Min(1), // body
+ Constraint::Length(1), // footer
+ ])
+ .split(f.area());
+
+ draw_header(f, chunks[0], app, theme);
+ draw_body(f, chunks[1], app, theme);
+ draw_footer(f, chunks[2], theme);
+}
+
+fn draw_header(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
+ let (state_label, state_color) = match &app.connection {
+ Connection::Connected { protocol } => {
+ (format!("● daemon connected (protocol v{protocol})"), theme.success)
+ }
+ Connection::Disconnected { reason } => (format!("○ disconnected — {reason}"), theme.danger),
+ };
+
+ let title = Line::from(vec![
+ Span::styled("✦ Hydra", Style::default().fg(theme.ghost).add_modifier(Modifier::BOLD)),
+ Span::styled(" · audio routing", Style::default().fg(theme.fg_dim)),
+ ]);
+ let status = Line::from(Span::styled(state_label, Style::default().fg(state_color)));
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(theme.border))
+ .style(Style::default().bg(theme.bg));
+ f.render_widget(Paragraph::new(vec![title, status]).block(block), area);
+}
+
+fn draw_body(f: &mut Frame, area: Rect, app: &App, theme: &Theme) {
+ let block = Block::default()
+ .title(Span::styled(" routes ", Style::default().fg(theme.accent)))
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(theme.border))
+ .style(Style::default().bg(theme.bg));
+
+ let body: Vec<Line> = if app.snapshot.routes.is_empty() {
+ vec![
+ Line::from(""),
+ Line::from(Span::styled(" No routes yet.", Style::default().fg(theme.fg_dim))),
+ Line::from(Span::styled(
+ " Per-app capture + virtual devices arrive in the next phases.",
+ Style::default().fg(theme.fg_dim),
+ )),
+ ]
+ } else {
+ app.snapshot
+ .routes
+ .iter()
+ .map(|r| {
+ let dot = if r.active { theme.success } else { theme.fg_dim };
+ Line::from(vec![
+ Span::styled(" ● ", Style::default().fg(dot)),
+ Span::styled(format!("{} ", r.target), Style::default().fg(theme.fg)),
+ Span::styled(
+ format!("◀ {} sources", r.source_count),
+ Style::default().fg(theme.fg_dim),
+ ),
+ ])
+ })
+ .collect()
+ };
+
+ f.render_widget(Paragraph::new(body).block(block), area);
+}
+
+fn draw_footer(f: &mut Frame, area: Rect, theme: &Theme) {
+ let hint = Line::from(vec![
+ key("r", theme),
+ Span::styled(" refresh ", Style::default().fg(theme.fg_dim)),
+ key("q", theme),
+ Span::styled(" quit", Style::default().fg(theme.fg_dim)),
+ ]);
+ f.render_widget(Paragraph::new(hint).style(Style::default().bg(theme.bg)), area);
+}
+
+fn key<'a>(k: &'a str, theme: &Theme) -> Span<'a> {
+ Span::styled(
+ format!(" {k} "),
+ Style::default().fg(theme.bg).bg(theme.ghost).add_modifier(Modifier::BOLD),
+ )
+}
diff --git a/crates/hydrad/Cargo.toml b/crates/hydrad/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "hydrad"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+
+[[bin]]
+name = "hydrad"
+path = "src/main.rs"
+
+[dependencies]
+hydra-core = { path = "../hydra-core" }
+hydra-ipc = { path = "../hydra-ipc" }
+serde_json.workspace = true
diff --git a/crates/hydrad/src/main.rs b/crates/hydrad/src/main.rs
@@ -0,0 +1,45 @@
+//! `hydrad` — the Hydra daemon. It owns all CoreAudio state (from P1) and is the only
+//! process that touches the audio engine. Clients connect over a Unix-domain socket.
+//!
+//! P0 scope: bind the socket, answer `Ping`/`GetState`, hold an (empty) routing state.
+
+mod server;
+
+use std::error::Error;
+use std::fs;
+use std::os::unix::fs::PermissionsExt;
+use std::os::unix::net::UnixListener;
+use std::sync::{Arc, Mutex};
+
+use hydra_core::RoutingState;
+
+fn main() -> Result<(), Box<dyn Error>> {
+ let sock = hydra_ipc::socket_path();
+ if let Some(dir) = sock.parent() {
+ fs::create_dir_all(dir)?;
+ }
+ // A stale socket from a previous run would make bind() fail with EADDRINUSE.
+ let _ = fs::remove_file(&sock);
+
+ let listener = UnixListener::bind(&sock)?;
+ fs::set_permissions(&sock, fs::Permissions::from_mode(0o600))?;
+ eprintln!("hydrad {} listening on {}", hydra_core::VERSION, sock.display());
+
+ let state = Arc::new(Mutex::new(RoutingState::default()));
+
+ for conn in listener.incoming() {
+ match conn {
+ Ok(stream) => {
+ let state = Arc::clone(&state);
+ std::thread::spawn(move || {
+ if let Err(e) = server::handle(stream, state) {
+ eprintln!("connection error: {e}");
+ }
+ });
+ }
+ Err(e) => eprintln!("accept error: {e}"),
+ }
+ }
+
+ Ok(())
+}
diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs
@@ -0,0 +1,31 @@
+//! Per-connection request handling. Each accepted socket runs on its own thread and
+//! processes a stream of NDJSON [`Command`]s, replying with a [`Response`] to each.
+
+use std::error::Error;
+use std::io::{BufReader, BufWriter};
+use std::os::unix::net::UnixStream;
+use std::sync::{Arc, Mutex};
+
+use hydra_core::RoutingState;
+use hydra_ipc::{read_msg, write_msg, Command, Response, PROTOCOL_VERSION};
+
+pub fn handle(stream: UnixStream, state: Arc<Mutex<RoutingState>>) -> Result<(), Box<dyn Error>> {
+ let mut reader = BufReader::new(stream.try_clone()?);
+ let mut writer = BufWriter::new(stream);
+
+ while let Some(cmd) = read_msg::<_, Command>(&mut reader)? {
+ let resp = match cmd {
+ Command::Ping => Response::Pong { version: PROTOCOL_VERSION },
+ Command::GetState => Response::State(state.lock().unwrap().snapshot()),
+ // Server-push subscription lands in P4; acknowledge for now.
+ Command::Subscribe => Response::Ok,
+ Command::Shutdown => {
+ write_msg(&mut writer, &Response::Ok)?;
+ std::process::exit(0);
+ }
+ };
+ write_msg(&mut writer, &resp)?;
+ }
+
+ Ok(())
+}
diff --git a/themes/navi.toml b/themes/navi.toml
@@ -0,0 +1,17 @@
+# Hydra theme — Navi palette (default).
+# Copy to ~/Library/Application Support/hydra/theme.toml and edit to swap palettes.
+# Any omitted key falls back to the built-in Navi default.
+
+name = "Navi"
+
+[palette]
+bg = "#101e2e" # Deep Night Blue
+bg_elevated = "#142c4a" # Space Cadet
+fg = "#e0f0ff" # Alice Blue
+fg_dim = "#7898b8" # Cadet Grey
+accent = "#f09030" # Amber — lone warm anchor
+ghost = "#40e8ff" # Electric Cyan — ghost light
+border = "#265080" # Payne's Grey
+success = "#00e898" # Caribbean Green
+warning = "#f09030" # Amber
+danger = "#ff6060" # Bittersweet