From 5454e7b690a892e1df311021db82755dc874bc30 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 10:20:06 -0700 Subject: [PATCH 01/13] feat(daemon): add dynamic port allocation support - Always create HTTP server regardless of port configuration - Support port 0 for dynamic allocation - Output actual port to stdout when using dynamic allocation - Enable WUI to start daemon on any available port --- hld/daemon/daemon.go | 9 +++------ hld/daemon/http_server.go | 27 +++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/hld/daemon/daemon.go b/hld/daemon/daemon.go index 6d86ec3..f7b4e8e 100644 --- a/hld/daemon/daemon.go +++ b/hld/daemon/daemon.go @@ -101,12 +101,9 @@ func New() (*Daemon, error) { approvalManager := approval.NewManager(conversationStore, eventBus) slog.Debug("local approval manager created successfully") - // Create HTTP server if enabled - var httpServer *HTTPServer - if cfg.HTTPPort > 0 { - slog.Info("creating HTTP server", "port", cfg.HTTPPort) - httpServer = NewHTTPServer(cfg, sessionManager, approvalManager, conversationStore, eventBus) - } + // Create HTTP server (always enabled, port 0 means dynamic allocation) + slog.Info("creating HTTP server", "port", cfg.HTTPPort) + httpServer := NewHTTPServer(cfg, sessionManager, approvalManager, conversationStore, eventBus) return &Daemon{ config: cfg, diff --git a/hld/daemon/http_server.go b/hld/daemon/http_server.go index 3b8c284..3817f09 100644 --- a/hld/daemon/http_server.go +++ b/hld/daemon/http_server.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "net" "net/http" "time" @@ -90,17 +91,35 @@ func (s *HTTPServer) Start(ctx context.Context) error { // Register SSE endpoint directly (not part of strict interface) v1.GET("/stream/events", s.sseHandler.StreamEvents) - // Create HTTP server + // Create listener first to handle port 0 addr := fmt.Sprintf("%s:%d", s.config.HTTPHost, s.config.HTTPPort) + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", addr, err) + } + + // Get actual port after binding + actualAddr := listener.Addr().(*net.TCPAddr) + actualPort := actualAddr.Port + + // If port 0 was used, output actual port to stdout + if s.config.HTTPPort == 0 { + fmt.Printf("HTTP_PORT=%d\n", actualPort) + } + + slog.Info("Starting HTTP server", + "configured_port", s.config.HTTPPort, + "actual_address", actualAddr.String()) + + // Create HTTP server s.server = &http.Server{ - Addr: addr, Handler: s.router, } // Start server in goroutine go func() { - slog.Info("Starting HTTP server", "address", addr) - if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + // Use the existing listener + if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed { slog.Error("HTTP server error", "error", err) } }() From 8758ce2d1db83e46fcc8a81066946002ac168155 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 10:20:25 -0700 Subject: [PATCH 02/13] feat(wui): add daemon process management infrastructure - Add Tauri shell and store plugins for process and state management - Implement Rust daemon manager with automatic startup on app launch - Add branch-based daemon isolation for development environments - Create TypeScript services and hooks for daemon lifecycle management - Add debug panel UI for manual daemon control in dev mode - Support automatic daemon connection with managed port discovery - Handle graceful daemon shutdown on app close - Implement retry logic for daemon connection during startup --- humanlayer-wui/src-tauri/Cargo.lock | 512 ++++++++++++++++-- humanlayer-wui/src-tauri/Cargo.toml | 7 + .../src-tauri/capabilities/default.json | 6 +- humanlayer-wui/src-tauri/src/daemon.rs | 274 ++++++++++ humanlayer-wui/src-tauri/src/lib.rs | 184 ++++++- humanlayer-wui/src/components/DebugPanel.tsx | 228 ++++++++ humanlayer-wui/src/components/Layout.tsx | 4 + .../src/hooks/useDaemonConnection.ts | 44 +- humanlayer-wui/src/lib/daemon/http-client.ts | 15 +- humanlayer-wui/src/lib/daemon/http-config.ts | 18 +- humanlayer-wui/src/lib/daemon/types.ts | 1 + humanlayer-wui/src/services/daemon-service.ts | 104 ++++ 12 files changed, 1356 insertions(+), 41 deletions(-) create mode 100644 humanlayer-wui/src-tauri/src/daemon.rs create mode 100644 humanlayer-wui/src/components/DebugPanel.tsx create mode 100644 humanlayer-wui/src/services/daemon-service.ts diff --git a/humanlayer-wui/src-tauri/Cargo.lock b/humanlayer-wui/src-tauri/Cargo.lock index f6b2d22..d5138e4 100644 --- a/humanlayer-wui/src-tauri/Cargo.lock +++ b/humanlayer-wui/src-tauri/Cargo.lock @@ -510,8 +510,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -559,6 +561,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -582,9 +594,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.9.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -595,7 +607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.9.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -747,13 +759,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[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 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -764,7 +797,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.0", "windows-sys 0.60.2", ] @@ -871,7 +904,7 @@ dependencies = [ "rustc_version", "toml", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -880,6 +913,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.0" @@ -1007,6 +1049,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1014,7 +1065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1028,6 +1079,12 @@ dependencies = [ "syn 2.0.103", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1454,6 +1511,25 @@ dependencies = [ "syn 2.0.103", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.9.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1502,6 +1578,17 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.3.1" @@ -1513,6 +1600,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1520,7 +1618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.3.1", ] [[package]] @@ -1531,8 +1629,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", ] @@ -1542,10 +1640,20 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "humanlayer-wui" version = "0.1.0" dependencies = [ + "chrono", + "dirs 5.0.1", + "regex", + "reqwest 0.11.27", "serde", "serde_json", "tauri", @@ -1554,10 +1662,37 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-shell", + "tauri-plugin-store", + "tokio", "tracing", "tracing-subscriber", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.6.0" @@ -1567,8 +1702,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -1577,6 +1712,19 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-util" version = "0.1.14" @@ -1588,9 +1736,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", - "hyper", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.6.0", "ipnet", "libc", "percent-encoding", @@ -2147,6 +2295,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2513,6 +2678,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.103", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3042,6 +3251,17 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.0" @@ -3082,6 +3302,46 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.12.20" @@ -3092,10 +3352,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.6.0", "hyper-util", "js-sys", "log", @@ -3104,7 +3364,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-util", "tower", @@ -3158,6 +3418,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -3179,6 +3448,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3212,6 +3490,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3407,12 +3708,44 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[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-registry" version = "1.4.5" @@ -3474,7 +3807,7 @@ dependencies = [ "bytemuck", "cfg_aliases", "core-graphics", - "foreign-types", + "foreign-types 0.5.0", "js-sys", "log", "objc2 0.5.2", @@ -3589,6 +3922,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3609,6 +3948,27 @@ dependencies = [ "syn 2.0.103", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3629,7 +3989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49c380ca75a231b87b6c9dd86948f035012e7171d1a7c40a9c2890489a7ffd8a" dependencies = [ "bitflags 2.9.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -3686,14 +4046,14 @@ checksum = "2f7a0f4019c80391d143ee26cd7cd1ed271ac241d3087d333f99f3269ba90812" dependencies = [ "anyhow", "bytes", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.2.16", "glob", "gtk", "heck 0.5.0", - "http", + "http 1.3.1", "jni", "libc", "log", @@ -3706,7 +4066,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.12.20", "serde", "serde_json", "serde_repr", @@ -3736,7 +4096,7 @@ checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3886,6 +4246,43 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-shell" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b9ffadec5c3523f11e8273465cacb3d86ea7652a28e6e2a2e9b5c182f791d25" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", +] + +[[package]] +name = "tauri-plugin-store" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916c609664a56c82aeaefffca9851fd072d4d41f73d63f22ee3ee451508194f" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.12", + "tokio", + "tracing", +] + [[package]] name = "tauri-runtime" version = "2.7.0" @@ -3895,7 +4292,7 @@ dependencies = [ "cookie", "dpi", "gtk", - "http", + "http 1.3.1", "jni", "objc2 0.6.1", "objc2-ui-kit", @@ -3915,7 +4312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe52ed0ef40fd7ad51a620ecb3018e32eba3040bb95025216a962a37f6f050c5" dependencies = [ "gtk", - "http", + "http 1.3.1", "jni", "log", "objc2 0.6.1", @@ -3948,7 +4345,7 @@ dependencies = [ "dunce", "glob", "html5ever", - "http", + "http 1.3.1", "infer", "json-patch", "kuchikiki", @@ -4131,11 +4528,35 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.103", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.15" @@ -4221,7 +4642,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -4236,8 +4657,8 @@ dependencies = [ "bitflags 2.9.1", "bytes", "futures-util", - "http", - "http-body", + "http 1.3.1", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -4321,7 +4742,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7eee98ec5c90daf179d55c20a49d8c0d043054ce7c26336c09a24d31f14fa0" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.1", @@ -4485,6 +4906,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" @@ -4964,6 +5391,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[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.52.0" @@ -5269,6 +5705,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.55.0" @@ -5328,7 +5774,7 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http", + "http 1.3.1", "javascriptcore-rs", "jni", "kuchikiki", diff --git a/humanlayer-wui/src-tauri/Cargo.toml b/humanlayer-wui/src-tauri/Cargo.toml index b2cd3be..31a6617 100644 --- a/humanlayer-wui/src-tauri/Cargo.toml +++ b/humanlayer-wui/src-tauri/Cargo.toml @@ -23,7 +23,14 @@ tauri-plugin-opener = "2" tauri-plugin-notification = "2.3.0" tauri-plugin-fs = "2" tauri-plugin-clipboard-manager = "2" +tauri-plugin-shell = "2" +tauri-plugin-store = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = "0.1" tracing-subscriber = "0.3" +tokio = { version = "1", features = ["full"] } +dirs = "5.0" +regex = "1.10" +reqwest = { version = "0.11", features = ["blocking"] } +chrono = { version = "0.4", features = ["serde"] } diff --git a/humanlayer-wui/src-tauri/capabilities/default.json b/humanlayer-wui/src-tauri/capabilities/default.json index 4e1e1af..772b16e 100644 --- a/humanlayer-wui/src-tauri/capabilities/default.json +++ b/humanlayer-wui/src-tauri/capabilities/default.json @@ -18,6 +18,10 @@ ] }, "clipboard-manager:default", - "clipboard-manager:allow-write-text" + "clipboard-manager:allow-write-text", + "shell:allow-spawn", + "shell:allow-execute", + "shell:allow-kill", + "store:default" ] } diff --git a/humanlayer-wui/src-tauri/src/daemon.rs b/humanlayer-wui/src-tauri/src/daemon.rs new file mode 100644 index 0000000..550d16a --- /dev/null +++ b/humanlayer-wui/src-tauri/src/daemon.rs @@ -0,0 +1,274 @@ +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::env; +use std::path::PathBuf; +use std::fs; +use std::io::{BufRead, BufReader}; +use tauri::{AppHandle, Manager}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonInfo { + pub port: u16, + pub pid: u32, + pub database_path: String, + pub socket_path: String, + pub branch_id: String, + pub is_running: bool, +} + +#[derive(Clone)] +pub struct DaemonManager { + process: Arc>>, + info: Arc>>, +} + +impl DaemonManager { + pub fn new() -> Self { + Self { + process: Arc::new(Mutex::new(None)), + info: Arc::new(Mutex::new(None)), + } + } + + pub fn start_daemon( + &self, + app_handle: &AppHandle, + is_dev: bool, + branch_override: Option + ) -> Result { + let mut process = self.process.lock().unwrap(); + + // Check if already running + if process.is_some() { + if let Some(info) = self.info.lock().unwrap().as_ref() { + return Ok(info.clone()); + } + } + + // Check if daemon is already running on a specific port + if let Ok(port_str) = env::var("HUMANLAYER_DAEMON_HTTP_PORT") { + if let Ok(port) = port_str.parse::() { + // Try to connect to existing daemon + if check_daemon_health(port).is_ok() { + let info = DaemonInfo { + port, + pid: 0, // Unknown PID for external daemon + database_path: "external".to_string(), + socket_path: "external".to_string(), + branch_id: "external".to_string(), + is_running: true, + }; + *self.info.lock().unwrap() = Some(info.clone()); + return Ok(info); + } + } + } + + // Determine branch identifier + let branch_id = if is_dev { + branch_override.or_else(get_git_branch) + .unwrap_or_else(|| "dev".to_string()) + } else { + "production".to_string() + }; + + // Extract ticket ID if present (e.g., "eng-1234" from "eng-1234-some-feature") + let branch_id = extract_ticket_id(&branch_id).unwrap_or(branch_id); + + // Set up paths + let home_dir = dirs::home_dir() + .ok_or("Failed to get home directory")?; + let humanlayer_dir = home_dir.join(".humanlayer"); + fs::create_dir_all(&humanlayer_dir) + .map_err(|e| format!("Failed to create .humanlayer directory: {e}"))?; + + // Database path + let database_path = if is_dev { + // Copy dev database if it doesn't exist + let dev_db = humanlayer_dir.join(format!("daemon-{branch_id}.db")); + if !dev_db.exists() { + let source_db = humanlayer_dir.join("daemon-dev.db"); + if source_db.exists() { + fs::copy(&source_db, &dev_db) + .map_err(|e| format!("Failed to copy dev database: {e}"))?; + } + } + dev_db + } else { + humanlayer_dir.join("daemon.db") + }; + + // Socket path + let socket_path = if is_dev { + humanlayer_dir.join(format!("daemon-{branch_id}.sock")) + } else { + humanlayer_dir.join("daemon.sock") + }; + + // Get daemon binary path (macOS only) + let daemon_path = get_daemon_path(app_handle, is_dev)?; + + // Build environment with port 0 for dynamic allocation + let mut env_vars = env::vars().collect::>(); + env_vars.push(("HUMANLAYER_DATABASE_PATH".to_string(), database_path.to_str().unwrap().to_string())); + env_vars.push(("HUMANLAYER_DAEMON_SOCKET".to_string(), socket_path.to_str().unwrap().to_string())); + env_vars.push(("HUMANLAYER_DAEMON_HTTP_PORT".to_string(), "0".to_string())); + env_vars.push(("HUMANLAYER_DAEMON_HTTP_HOST".to_string(), "127.0.0.1".to_string())); + + if is_dev { + env_vars.push(("HUMANLAYER_DAEMON_VERSION_OVERRIDE".to_string(), branch_id.clone())); + } + + // Start daemon with stdout capture + let mut cmd = Command::new(&daemon_path); + cmd.envs(env_vars) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); // Let stderr go to console for debugging + + let mut child = cmd.spawn() + .map_err(|e| format!("Failed to start daemon: {e}"))?; + + // Parse stdout to get the actual port + let stdout = child.stdout.take() + .ok_or("Failed to capture daemon stdout")?; + + let reader = BufReader::new(stdout); + let mut actual_port = None; + + for line in reader.lines().map_while(Result::ok) { + if line.starts_with("HTTP_PORT=") { + actual_port = line.replace("HTTP_PORT=", "") + .parse::() + .ok(); + break; + } + } + + let port = actual_port.ok_or("Daemon failed to report port")?; + + let daemon_info = DaemonInfo { + port, + pid: child.id(), + database_path: database_path.to_str().unwrap().to_string(), + socket_path: socket_path.to_str().unwrap().to_string(), + branch_id: branch_id.clone(), + is_running: true, + }; + + *process = Some(child); + *self.info.lock().unwrap() = Some(daemon_info.clone()); + + // Wait for daemon to be ready + wait_for_daemon(port)?; + + Ok(daemon_info) + } + + pub fn stop_daemon(&self) -> Result<(), String> { + let mut process = self.process.lock().unwrap(); + + if let Some(mut child) = process.take() { + child.kill() + .map_err(|e| format!("Failed to stop daemon: {e}"))?; + + // Wait for process to exit + let _ = child.wait(); + + // Update store to mark daemon as not running + if let Some(info) = self.info.lock().unwrap().as_mut() { + info.is_running = false; + } + } + + Ok(()) + } + + pub fn get_info(&self) -> Option { + self.info.lock().unwrap().clone() + } + + pub fn is_running(&self) -> bool { + let mut process = self.process.lock().unwrap(); + if let Some(child) = process.as_mut() { + // Check if process is still alive + match child.try_wait() { + Ok(None) => true, // Still running + _ => false, // Exited or error + } + } else { + false + } + } +} + +fn get_daemon_path(app_handle: &AppHandle, is_dev: bool) -> Result { + if is_dev { + // In dev mode, look for hld-dev in the project + let dev_path = env::current_dir() + .map_err(|e| format!("Failed to get current directory: {e}"))? + .parent() // Go up from humanlayer-wui to humanlayer + .ok_or("Failed to get parent directory")? + .join("hld") + .join("hld-dev"); + + if dev_path.exists() { + Ok(dev_path) + } else { + Err("Development daemon not found. Run 'make daemon-dev-build' first.".to_string()) + } + } else { + // In production, use bundled binary + let resource_dir = app_handle.path() + .resource_dir() + .map_err(|e| format!("Failed to get resource directory: {e}"))?; + + Ok(resource_dir.join("bin").join("hld")) + } +} + +fn get_git_branch() -> Option { + Command::new("git") + .args(["branch", "--show-current"]) + .output() + .ok() + .and_then(|output| { + if output.status.success() { + String::from_utf8(output.stdout).ok() + .map(|s| s.trim().to_string()) + } else { + None + } + }) +} + +fn extract_ticket_id(branch: &str) -> Option { + // Extract patterns like "eng-1234" from branch names + let re = regex::Regex::new(r"(eng-\d+)").ok()?; + re.captures(branch) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) +} + +fn check_daemon_health(port: u16) -> Result<(), String> { + match reqwest::blocking::get(format!("http://127.0.0.1:{port}/api/v1/health")) { + Ok(response) if response.status().is_success() => Ok(()), + Ok(_) => Err("Daemon health check failed".to_string()), + Err(e) => Err(format!("Failed to connect to daemon: {e}")), + } +} + +fn wait_for_daemon(port: u16) -> Result<(), String> { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + while start.elapsed() < timeout { + if check_daemon_health(port).is_ok() { + return Ok(()); + } + + std::thread::sleep(std::time::Duration::from_millis(500)); + } + + Err("Daemon failed to start within 10 seconds".to_string()) +} \ No newline at end of file diff --git a/humanlayer-wui/src-tauri/src/lib.rs b/humanlayer-wui/src-tauri/src/lib.rs index 95a94ed..5b3d22e 100644 --- a/humanlayer-wui/src-tauri/src/lib.rs +++ b/humanlayer-wui/src-tauri/src/lib.rs @@ -1,13 +1,195 @@ +mod daemon; + +use daemon::{DaemonManager, DaemonInfo}; +use tauri::{Manager, State}; +use tauri_plugin_store::StoreExt; +use std::env; +use std::path::PathBuf; + +// Helper to get store path based on dev mode and branch +fn get_store_path(is_dev: bool, branch_id: Option<&str>) -> PathBuf { + let home = dirs::home_dir().expect("Failed to get home directory"); + let humanlayer_dir = home.join(".humanlayer"); + + if is_dev { + // Use branch-specific store in dev mode + if let Some(branch) = branch_id { + humanlayer_dir.join(format!("codelayer-{branch}.json")) + } else { + humanlayer_dir.join("codelayer-dev.json") + } + } else { + humanlayer_dir.join("codelayer.json") + } +} + +#[tauri::command] +async fn start_daemon( + app_handle: tauri::AppHandle, + daemon_manager: State<'_, DaemonManager>, + is_dev: bool, + branch_override: Option, +) -> Result { + let info = daemon_manager.start_daemon(&app_handle, is_dev, branch_override)?; + + // Save to store using branch-specific path in dev mode + let store_path = get_store_path(is_dev, Some(&info.branch_id)); + let store = app_handle.store(&store_path) + .map_err(|e| format!("Failed to access store: {e}"))?; + + store.set("current_daemon", serde_json::to_value(&info).unwrap()); + store.save() + .map_err(|e| format!("Failed to save store: {e}"))?; + + Ok(info) +} + +#[tauri::command] +async fn stop_daemon( + app_handle: tauri::AppHandle, + daemon_manager: State<'_, DaemonManager>, +) -> Result<(), String> { + // Get daemon info before stopping to know which store to update + if let Some(info) = daemon_manager.get_info() { + let is_dev = info.branch_id != "production"; + let store_path = get_store_path(is_dev, Some(&info.branch_id)); + + // Stop the daemon + daemon_manager.stop_daemon()?; + + // Update store to mark as not running + let store = app_handle.store(&store_path) + .map_err(|e| format!("Failed to access store: {e}"))?; + + // Get the stored daemon info and update is_running + if let Some(mut stored_info) = store.get("current_daemon") + .and_then(|v| serde_json::from_value::(v).ok()) { + stored_info.is_running = false; + store.set("current_daemon", serde_json::to_value(&stored_info).unwrap()); + store.save() + .map_err(|e| format!("Failed to save store: {e}"))?; + } + } else { + daemon_manager.stop_daemon()?; + } + + Ok(()) +} + +#[tauri::command] +async fn get_daemon_info( + app_handle: tauri::AppHandle, + daemon_manager: State<'_, DaemonManager>, + is_dev: bool, +) -> Result, String> { + // First check if daemon manager has info + if let Some(info) = daemon_manager.get_info() { + return Ok(Some(info)); + } + + // Otherwise check store for last known daemon + let store_path = get_store_path(is_dev, None); + let store = app_handle.store(&store_path) + .map_err(|e| format!("Failed to access store: {e}"))?; + + let stored_info = store.get("current_daemon") + .and_then(|v| serde_json::from_value::(v).ok()); + + Ok(stored_info) +} + +#[tauri::command] +async fn is_daemon_running(daemon_manager: State<'_, DaemonManager>) -> Result { + Ok(daemon_manager.is_running()) +} + + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Initialize tracing tracing_subscriber::fmt::init(); tauri::Builder::default() - .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_store::Builder::new().build()) + .setup(|app| { + let daemon_manager = DaemonManager::new(); + app.manage(daemon_manager.clone()); + + // Check if auto-launch is disabled + let should_autolaunch = env::var("HUMANLAYER_WUI_AUTOLAUNCH_DAEMON") + .map(|v| v.to_lowercase() != "false") + .unwrap_or(true); + + if should_autolaunch { + // Start daemon automatically + let app_handle_clone = app.app_handle().clone(); + tauri::async_runtime::spawn(async move { + let is_dev = cfg!(debug_assertions); + + // Small delay to ensure UI is ready + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // Try to start daemon, but don't show errors in UI + match daemon_manager.start_daemon(&app_handle_clone, is_dev, None) { + Ok(info) => { + tracing::info!("Daemon started automatically on port {}", info.port); + } + Err(e) => { + // Log error but don't interrupt user experience + tracing::error!("Failed to auto-start daemon: {}", e); + + // Common edge cases that shouldn't interrupt the user + if e.contains("port") || e.contains("already in use") { + tracing::info!("Port conflict detected, user can connect manually"); + } else if e.contains("binary not found") || e.contains("daemon not found") { + tracing::warn!("Daemon binary missing, this is expected in some dev scenarios"); + } else if e.contains("permission") { + tracing::error!("Permission issue starting daemon, user intervention required"); + } + } + } + }); + } else { + tracing::info!("Daemon auto-launch disabled by environment variable"); + } + + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + let daemon_manager = window.state::(); + + // Always stop daemon when window closes + if let Some(info) = daemon_manager.get_info() { + let is_dev = info.branch_id != "production"; + let store_path = get_store_path(is_dev, Some(&info.branch_id)); + + if let Ok(store) = window.app_handle().store(&store_path) { + // Update store to mark daemon as not running + if let Some(mut stored_info) = store.get("current_daemon") + .and_then(|v| serde_json::from_value::(v).ok()) { + stored_info.is_running = false; + store.set("current_daemon", serde_json::to_value(&stored_info).unwrap()); + let _ = store.save(); + } + } + + // Stop the daemon + let _ = daemon_manager.stop_daemon(); + } + } + }) + .invoke_handler(tauri::generate_handler![ + start_daemon, + stop_daemon, + get_daemon_info, + is_daemon_running, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/humanlayer-wui/src/components/DebugPanel.tsx b/humanlayer-wui/src/components/DebugPanel.tsx new file mode 100644 index 0000000..cb88e22 --- /dev/null +++ b/humanlayer-wui/src/components/DebugPanel.tsx @@ -0,0 +1,228 @@ +import { useState, useEffect } from 'react' +import { daemonService, type DaemonInfo } from '@/services/daemon-service' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Loader2, RefreshCw, Link, Settings, Server } from 'lucide-react' +import { useDaemonConnection } from '@/hooks/useDaemonConnection' + +export function DebugPanel() { + const { connected, reconnect } = useDaemonConnection() + const [isRestarting, setIsRestarting] = useState(false) + const [restartError, setRestartError] = useState(null) + const [customUrl, setCustomUrl] = useState('') + const [connectError, setConnectError] = useState(null) + const [daemonType, setDaemonType] = useState<'managed' | 'external'>('managed') + const [externalDaemonUrl, setExternalDaemonUrl] = useState(null) + const [daemonInfo, setDaemonInfo] = useState(null) + + // Only show in dev mode + if (!import.meta.env.DEV) { + return null + } + + useEffect(() => { + loadDaemonInfo() + }, [connected]) + + async function loadDaemonInfo() { + try { + const info = await daemonService.getDaemonInfo() + setDaemonInfo(info) + const type = daemonService.getDaemonType() + setDaemonType(type) + if (type === 'external') { + setExternalDaemonUrl((window as any).__HUMANLAYER_DAEMON_URL || null) + } + } catch (error) { + console.error('Failed to load daemon info:', error) + } + } + + async function handleRestartDaemon() { + setIsRestarting(true) + setRestartError(null) + + try { + // Stop current daemon + await daemonService.stopDaemon() + + // Wait a moment + await new Promise(resolve => setTimeout(resolve, 1000)) + + // Start new daemon (with rebuild in dev) + await daemonService.startDaemon(true) + + // Wait for it to be ready + await new Promise(resolve => setTimeout(resolve, 2000)) + + // Reconnect + await reconnect() + await loadDaemonInfo() + + setIsRestarting(false) + } catch (error: any) { + setRestartError(error.message || 'Failed to restart daemon') + setIsRestarting(false) + } + } + + async function handleConnectToCustom() { + setConnectError(null) + + try { + await daemonService.connectToExisting(customUrl) + await reconnect() + await loadDaemonInfo() + setCustomUrl('') + } catch (error: any) { + setConnectError(error.message || 'Failed to connect') + } + } + + async function handleSwitchToManaged() { + try { + await daemonService.switchToManagedDaemon() + await reconnect() + await loadDaemonInfo() + } catch (error: any) { + setConnectError(error.message || 'Failed to switch to managed daemon') + } + } + + return ( + + + + + + + Debug Panel + Advanced daemon management for developers + +
+ + + Daemon Status + + +
+ Connection + + {connected ? 'Connected' : 'Disconnected'} + +
+ +
+ Daemon Type +
+ + {daemonType} +
+
+ + {daemonType === 'external' && externalDaemonUrl && ( +
+ External URL + {externalDaemonUrl} +
+ )} + + {daemonInfo && ( + <> +
+ Port + {daemonInfo.port} +
+
+ Branch + {daemonInfo.branch_id} +
+ + )} + + {daemonType === 'managed' ? ( + + ) : ( + + )} + + {restartError &&

{restartError}

} +
+
+ + + + Connect to Existing Daemon + + Connect to a daemon running on a custom URL + + + +
+ + setCustomUrl(e.target.value)} + /> +
+ + + + {connectError &&

{connectError}

} +
+
+
+
+
+ ) +} diff --git a/humanlayer-wui/src/components/Layout.tsx b/humanlayer-wui/src/components/Layout.tsx index 6311510..a460a73 100644 --- a/humanlayer-wui/src/components/Layout.tsx +++ b/humanlayer-wui/src/components/Layout.tsx @@ -17,6 +17,7 @@ import { useTheme } from '@/contexts/ThemeContext' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { MessageCircle } from 'lucide-react' import { openUrl } from '@tauri-apps/plugin-opener' +import { DebugPanel } from '@/components/DebugPanel' import '@/App.css' export function Layout() { @@ -264,6 +265,9 @@ export function Layout() { {/* Notifications */} + + {/* Debug Panel */} + ) } diff --git a/humanlayer-wui/src/hooks/useDaemonConnection.ts b/humanlayer-wui/src/hooks/useDaemonConnection.ts index edccbf4..33f94d5 100644 --- a/humanlayer-wui/src/hooks/useDaemonConnection.ts +++ b/humanlayer-wui/src/hooks/useDaemonConnection.ts @@ -1,6 +1,7 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { daemonClient } from '@/lib/daemon' import { formatError } from '@/utils/errors' +import { daemonService } from '@/services/daemon-service' interface UseDaemonConnectionReturn { connected: boolean @@ -8,6 +9,7 @@ interface UseDaemonConnectionReturn { error: string | null version: string | null connect: () => Promise + reconnect: () => Promise checkHealth: () => Promise } @@ -16,6 +18,7 @@ export function useDaemonConnection(): UseDaemonConnectionReturn { const [connecting, setConnecting] = useState(false) const [error, setError] = useState(null) const [version, setVersion] = useState(null) + const retryCount = useRef(0) const checkHealth = useCallback(async () => { try { @@ -31,11 +34,47 @@ export function useDaemonConnection(): UseDaemonConnectionReturn { }, []) const connect = useCallback(async () => { + if (connecting) return + + setConnecting(true) + setError(null) + + try { + await daemonClient.connect() + const health = await daemonClient.health() + + setConnected(true) + setVersion(health.version) + retryCount.current = 0 + } catch (err: any) { + setConnected(false) + + // Check if this is first failure and we have a managed daemon + if (retryCount.current === 0) { + const isManaged = await daemonService.isDaemonRunning() + if (!isManaged) { + // Let DaemonManager handle it + setError(formatError(err)) + } else { + // Managed daemon might be starting, retry + retryCount.current++ + setTimeout(() => connect(), 2000) + } + } else { + setError(formatError(err)) + } + } finally { + setConnecting(false) + } + }, []) + + const reconnect = useCallback(async () => { try { setConnecting(true) setError(null) + setConnected(false) - await daemonClient.connect() + await daemonClient.reconnect() await checkHealth() } catch (err) { setError(formatError(err)) @@ -64,6 +103,7 @@ export function useDaemonConnection(): UseDaemonConnectionReturn { error, version, connect, + reconnect, checkHealth, } } diff --git a/humanlayer-wui/src/lib/daemon/http-client.ts b/humanlayer-wui/src/lib/daemon/http-client.ts index b86b41c..f2a350c 100644 --- a/humanlayer-wui/src/lib/daemon/http-client.ts +++ b/humanlayer-wui/src/lib/daemon/http-client.ts @@ -61,7 +61,8 @@ export class HTTPDaemonClient implements IDaemonClient { } private async doConnect(): Promise { - const baseUrl = getDaemonUrl() + // getDaemonUrl now checks for managed daemon port dynamically + const baseUrl = await getDaemonUrl() this.client = new HLDClient({ baseUrl: `${baseUrl}/api/v1`, @@ -82,6 +83,16 @@ export class HTTPDaemonClient implements IDaemonClient { } } + async reconnect(): Promise { + // Disconnect first if connected + if (this.connected || this.connectionPromise) { + await this.disconnect() + } + + // Now connect to the potentially new URL + return this.connect() + } + async disconnect(): Promise { // Unsubscribe all event streams for (const unsubscribe of this.subscriptions.values()) { @@ -91,6 +102,8 @@ export class HTTPDaemonClient implements IDaemonClient { this.connected = false this.client = undefined + this.connectionPromise = undefined + this.retryCount = 0 } async health(): Promise { diff --git a/humanlayer-wui/src/lib/daemon/http-config.ts b/humanlayer-wui/src/lib/daemon/http-config.ts index b969850..31913c6 100644 --- a/humanlayer-wui/src/lib/daemon/http-config.ts +++ b/humanlayer-wui/src/lib/daemon/http-config.ts @@ -1,10 +1,22 @@ -// Get daemon URL from environment or default -export function getDaemonUrl(): string { +import { daemonService } from '@/services/daemon-service' + +// Get daemon URL from environment or managed daemon +export async function getDaemonUrl(): Promise { // Check for explicit URL first if (import.meta.env.VITE_HUMANLAYER_DAEMON_URL) { return import.meta.env.VITE_HUMANLAYER_DAEMON_URL } + // Check if we have a managed daemon + try { + const daemonInfo = await daemonService.getDaemonInfo() + if (daemonInfo && daemonInfo.port) { + return `http://127.0.0.1:${daemonInfo.port}` + } + } catch (error) { + console.warn('Failed to get managed daemon info:', error) + } + // Check for port override const port = import.meta.env.VITE_HUMANLAYER_DAEMON_HTTP_PORT || '7777' const host = import.meta.env.VITE_HUMANLAYER_DAEMON_HTTP_HOST || 'localhost' @@ -15,7 +27,7 @@ export function getDaemonUrl(): string { // Headers to include with all requests export function getDefaultHeaders(): Record { return { - 'X-Client': 'wui', + 'X-Client': 'codelayer', 'X-Client-Version': import.meta.env.VITE_APP_VERSION || 'unknown', } } diff --git a/humanlayer-wui/src/lib/daemon/types.ts b/humanlayer-wui/src/lib/daemon/types.ts index 1e001bb..f06bb34 100644 --- a/humanlayer-wui/src/lib/daemon/types.ts +++ b/humanlayer-wui/src/lib/daemon/types.ts @@ -48,6 +48,7 @@ export interface SubscriptionHandle { // Client interface using legacy types for backward compatibility export interface DaemonClient { connect(): Promise + reconnect(): Promise disconnect(): Promise health(): Promise diff --git a/humanlayer-wui/src/services/daemon-service.ts b/humanlayer-wui/src/services/daemon-service.ts new file mode 100644 index 0000000..af3b94c --- /dev/null +++ b/humanlayer-wui/src/services/daemon-service.ts @@ -0,0 +1,104 @@ +import { invoke } from '@tauri-apps/api/core' + +export interface DaemonInfo { + port: number + pid: number + database_path: string + socket_path: string + branch_id: string +} + +export interface DaemonConfig { + branch_id: string + port: number + database_path: string + socket_path: string + last_used: string +} + +class DaemonService { + private static instance: DaemonService + + static getInstance(): DaemonService { + if (!DaemonService.instance) { + DaemonService.instance = new DaemonService() + } + return DaemonService.instance + } + + async startDaemon(isDev: boolean = false, branchOverride?: string): Promise { + try { + return await invoke('start_daemon', { isDev, branchOverride }) + } catch (error) { + throw new Error(`Failed to start daemon: ${error}`) + } + } + + async stopDaemon(): Promise { + try { + await invoke('stop_daemon') + } catch (error) { + throw new Error(`Failed to stop daemon: ${error}`) + } + } + + async getDaemonInfo(): Promise { + try { + return await invoke('get_daemon_info') + } catch (error) { + console.error('Failed to get daemon info:', error) + return null + } + } + + async isDaemonRunning(): Promise { + try { + return await invoke('is_daemon_running') + } catch (error) { + console.error('Failed to check daemon status:', error) + return false + } + } + + async getStoredConfigs(): Promise { + try { + return await invoke('get_stored_configs') + } catch (error) { + console.error('Failed to get stored configs:', error) + return [] + } + } + + async getConfigPath(): Promise { + try { + return await invoke('get_config_path') + } catch { + return 'unknown' + } + } + + async connectToExisting(url: string): Promise { + // Validate URL format + try { + new URL(url) + } catch { + throw new Error(`Invalid URL format: ${url}`) + } + + // Store the custom URL temporarily (not in Tauri store) + ;(window as any).__HUMANLAYER_DAEMON_URL = url + ;(window as any).__HUMANLAYER_DAEMON_TYPE = 'external' + } + + async switchToManagedDaemon(): Promise { + // Clear the custom URL to switch back to managed daemon + delete (window as any).__HUMANLAYER_DAEMON_URL + delete (window as any).__HUMANLAYER_DAEMON_TYPE + } + + getDaemonType(): 'managed' | 'external' { + return (window as any).__HUMANLAYER_DAEMON_TYPE || 'managed' + } +} + +export const daemonService = DaemonService.getInstance() From dfb82c7c99cf5c978682447d277b39bae1363c58 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 10:53:06 -0700 Subject: [PATCH 03/13] feat(wui): add daemon process management infrastructure --- humanlayer-wui/src-tauri/src/daemon.rs | 70 +++++++++++++------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/humanlayer-wui/src-tauri/src/daemon.rs b/humanlayer-wui/src-tauri/src/daemon.rs index 550d16a..14f0e94 100644 --- a/humanlayer-wui/src-tauri/src/daemon.rs +++ b/humanlayer-wui/src-tauri/src/daemon.rs @@ -30,22 +30,22 @@ impl DaemonManager { info: Arc::new(Mutex::new(None)), } } - + pub fn start_daemon( - &self, + &self, app_handle: &AppHandle, is_dev: bool, branch_override: Option ) -> Result { let mut process = self.process.lock().unwrap(); - + // Check if already running if process.is_some() { if let Some(info) = self.info.lock().unwrap().as_ref() { return Ok(info.clone()); } } - + // Check if daemon is already running on a specific port if let Ok(port_str) = env::var("HUMANLAYER_DAEMON_HTTP_PORT") { if let Ok(port) = port_str.parse::() { @@ -64,7 +64,7 @@ impl DaemonManager { } } } - + // Determine branch identifier let branch_id = if is_dev { branch_override.or_else(get_git_branch) @@ -72,17 +72,17 @@ impl DaemonManager { } else { "production".to_string() }; - + // Extract ticket ID if present (e.g., "eng-1234" from "eng-1234-some-feature") let branch_id = extract_ticket_id(&branch_id).unwrap_or(branch_id); - + // Set up paths let home_dir = dirs::home_dir() .ok_or("Failed to get home directory")?; let humanlayer_dir = home_dir.join(".humanlayer"); fs::create_dir_all(&humanlayer_dir) .map_err(|e| format!("Failed to create .humanlayer directory: {e}"))?; - + // Database path let database_path = if is_dev { // Copy dev database if it doesn't exist @@ -98,44 +98,44 @@ impl DaemonManager { } else { humanlayer_dir.join("daemon.db") }; - + // Socket path let socket_path = if is_dev { humanlayer_dir.join(format!("daemon-{branch_id}.sock")) } else { humanlayer_dir.join("daemon.sock") }; - + // Get daemon binary path (macOS only) let daemon_path = get_daemon_path(app_handle, is_dev)?; - + // Build environment with port 0 for dynamic allocation let mut env_vars = env::vars().collect::>(); env_vars.push(("HUMANLAYER_DATABASE_PATH".to_string(), database_path.to_str().unwrap().to_string())); env_vars.push(("HUMANLAYER_DAEMON_SOCKET".to_string(), socket_path.to_str().unwrap().to_string())); env_vars.push(("HUMANLAYER_DAEMON_HTTP_PORT".to_string(), "0".to_string())); env_vars.push(("HUMANLAYER_DAEMON_HTTP_HOST".to_string(), "127.0.0.1".to_string())); - + if is_dev { env_vars.push(("HUMANLAYER_DAEMON_VERSION_OVERRIDE".to_string(), branch_id.clone())); } - + // Start daemon with stdout capture let mut cmd = Command::new(&daemon_path); cmd.envs(env_vars) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); // Let stderr go to console for debugging - + let mut child = cmd.spawn() .map_err(|e| format!("Failed to start daemon: {e}"))?; - + // Parse stdout to get the actual port let stdout = child.stdout.take() .ok_or("Failed to capture daemon stdout")?; - + let reader = BufReader::new(stdout); let mut actual_port = None; - + for line in reader.lines().map_while(Result::ok) { if line.starts_with("HTTP_PORT=") { actual_port = line.replace("HTTP_PORT=", "") @@ -144,9 +144,9 @@ impl DaemonManager { break; } } - + let port = actual_port.ok_or("Daemon failed to report port")?; - + let daemon_info = DaemonInfo { port, pid: child.id(), @@ -155,39 +155,39 @@ impl DaemonManager { branch_id: branch_id.clone(), is_running: true, }; - + *process = Some(child); *self.info.lock().unwrap() = Some(daemon_info.clone()); - + // Wait for daemon to be ready wait_for_daemon(port)?; - + Ok(daemon_info) } - + pub fn stop_daemon(&self) -> Result<(), String> { let mut process = self.process.lock().unwrap(); - + if let Some(mut child) = process.take() { child.kill() .map_err(|e| format!("Failed to stop daemon: {e}"))?; - + // Wait for process to exit let _ = child.wait(); - + // Update store to mark daemon as not running if let Some(info) = self.info.lock().unwrap().as_mut() { info.is_running = false; } } - + Ok(()) } - + pub fn get_info(&self) -> Option { self.info.lock().unwrap().clone() } - + pub fn is_running(&self) -> bool { let mut process = self.process.lock().unwrap(); if let Some(child) = process.as_mut() { @@ -211,7 +211,7 @@ fn get_daemon_path(app_handle: &AppHandle, is_dev: bool) -> Result Result Result<(), String> { fn wait_for_daemon(port: u16) -> Result<(), String> { let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(10); - + while start.elapsed() < timeout { if check_daemon_health(port).is_ok() { return Ok(()); } - + std::thread::sleep(std::time::Duration::from_millis(500)); } - + Err("Daemon failed to start within 10 seconds".to_string()) -} \ No newline at end of file +} From afad0224672461ce941042ae4ca8f84216239fa2 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 10:55:29 -0700 Subject: [PATCH 04/13] chore(wui): update gitignore for daemon artifacts --- humanlayer-wui/.gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/humanlayer-wui/.gitignore b/humanlayer-wui/.gitignore index c063067..00575e6 100644 --- a/humanlayer-wui/.gitignore +++ b/humanlayer-wui/.gitignore @@ -25,3 +25,7 @@ dist-ssr # Generated Tauri config for local development src-tauri/tauri.conf.local.json + +# Bundled binaries (created during build) +src-tauri/bin/ +!src-tauri/bin/.gitkeep From a6c9c9363ce7d0dd8fed2b1f2dd904aed5a74165 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 10:58:30 -0700 Subject: [PATCH 05/13] build(wui): update Tauri bundle configuration --- humanlayer-wui/src-tauri/tauri.conf.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/humanlayer-wui/src-tauri/tauri.conf.json b/humanlayer-wui/src-tauri/tauri.conf.json index be6cec6..5b6c10f 100644 --- a/humanlayer-wui/src-tauri/tauri.conf.json +++ b/humanlayer-wui/src-tauri/tauri.conf.json @@ -31,7 +31,8 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "resources": ["bin/hld", "bin/humanlayer"] }, "plugins": { "fs": { From 97d75cb2f052322ed87f1da26c4eaf86d6ac7ed8 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 10:59:37 -0700 Subject: [PATCH 06/13] build: add homebrew formula and update CI for daemon bundling --- .github/workflows/release-macos.yml | 24 +++++- Makefile | 38 +++++++++ homebrew-humanlayer/Casks/codelayer.rb | 32 ++++++++ homebrew-humanlayer/README.md | 105 +++++++++++++++++++++++++ humanlayer-wui/README.md | 105 +++++++++++++++++++++++-- 5 files changed, 292 insertions(+), 12 deletions(-) create mode 100644 homebrew-humanlayer/Casks/codelayer.rb create mode 100644 homebrew-humanlayer/README.md diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index 7001fd4..616fce3 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -48,16 +48,32 @@ jobs: working-directory: humanlayer-wui run: bun install + - name: Build daemon for macOS ARM + working-directory: hld + run: GOOS=darwin GOARCH=arm64 go build -o hld-darwin-arm64 ./cmd/hld + + - name: Build humanlayer CLI for macOS ARM + working-directory: hlyr + run: | + bun install + bun run build + bun build ./dist/index.js --compile --target=bun-darwin-arm64 --outfile=humanlayer-darwin-arm64 + chmod +x humanlayer-darwin-arm64 + + - name: Copy binaries to Tauri resources + run: | + mkdir -p humanlayer-wui/src-tauri/bin + cp hld/hld-darwin-arm64 humanlayer-wui/src-tauri/bin/hld + cp hlyr/humanlayer-darwin-arm64 humanlayer-wui/src-tauri/bin/humanlayer + chmod +x humanlayer-wui/src-tauri/bin/hld + chmod +x humanlayer-wui/src-tauri/bin/humanlayer + - name: Build Tauri app (including DMG) working-directory: humanlayer-wui run: bun run tauri build env: APPLE_SIGNING_IDENTITY: "-" # Ad-hoc signing to prevent "damaged" error - - name: Build daemon for macOS ARM - working-directory: hld - run: GOOS=darwin GOARCH=arm64 go build -o hld-darwin-arm64 ./cmd/hld - - name: Upload DMG artifact uses: actions/upload-artifact@v4 with: diff --git a/Makefile b/Makefile index c31e309..e37511c 100644 --- a/Makefile +++ b/Makefile @@ -502,6 +502,39 @@ wui-nightly-build: cp -r humanlayer-wui/src-tauri/target/release/bundle/macos/CodeLayer.app ~/Applications/ @echo "Installed WUI nightly to ~/Applications/CodeLayer.app" +# Build humanlayer binary for bundling +.PHONY: humanlayer-build +humanlayer-build: + @echo "Building humanlayer CLI binary..." + cd hlyr && bun install && bun run build + @echo "humanlayer binary built at hlyr/dist/index.js" + +# Build humanlayer standalone binary (requires bun) +.PHONY: humanlayer-binary-darwin-arm64 +humanlayer-binary-darwin-arm64: humanlayer-build + @echo "Creating standalone humanlayer binary for macOS ARM64..." + cd hlyr && bun build ./dist/index.js --compile --target=bun-darwin-arm64 --outfile=humanlayer-darwin-arm64 + chmod +x hlyr/humanlayer-darwin-arm64 + @echo "Standalone binary created at hlyr/humanlayer-darwin-arm64" + +# Build CodeLayer with bundled daemon and humanlayer +.PHONY: codelayer-bundle +codelayer-bundle: + @echo "Building daemon for bundling..." + cd hld && GOOS=darwin GOARCH=arm64 go build -o hld-darwin-arm64 ./cmd/hld + @echo "Building humanlayer for bundling..." + cd hlyr && bun install && bun run build + cd hlyr && bun build ./dist/index.js --compile --target=bun-darwin-arm64 --outfile=humanlayer-darwin-arm64 + @echo "Copying binaries to CodeLayer resources..." + mkdir -p humanlayer-wui/src-tauri/bin + cp hld/hld-darwin-arm64 humanlayer-wui/src-tauri/bin/hld + cp hlyr/humanlayer-darwin-arm64 humanlayer-wui/src-tauri/bin/humanlayer + chmod +x humanlayer-wui/src-tauri/bin/hld + chmod +x humanlayer-wui/src-tauri/bin/humanlayer + @echo "Building CodeLayer with bundled binaries..." + cd humanlayer-wui && bun run tauri build + @echo "Build complete!" + # Open nightly WUI .PHONY: wui-nightly wui-nightly: wui-nightly-build @@ -572,6 +605,7 @@ daemon-dev: daemon-dev-build echo "$(TIMESTAMP) starting dev daemon in $$(pwd)" > ~/.humanlayer/logs/daemon-dev-$(TIMESTAMP).log cd hld && HUMANLAYER_DATABASE_PATH=~/.humanlayer/daemon-dev.db \ HUMANLAYER_DAEMON_SOCKET=~/.humanlayer/daemon-dev.sock \ + HUMANLAYER_DAEMON_HTTP_PORT=0 \ HUMANLAYER_DAEMON_VERSION_OVERRIDE=$$(git branch --show-current) \ ./hld-dev 2>&1 | tee -a ~/.humanlayer/logs/daemon-dev-$(TIMESTAMP).log @@ -583,6 +617,10 @@ wui-dev: echo "$(TIMESTAMP) starting dev wui in $$(pwd)" > ~/.humanlayer/logs/wui-dev-$(TIMESTAMP).log cd humanlayer-wui && HUMANLAYER_DAEMON_SOCKET=~/.humanlayer/daemon-dev.sock bun run tauri dev 2>&1 | tee -a ~/.humanlayer/logs/wui-dev-$(TIMESTAMP).log +# Alias for wui-dev +.PHONY: codelayer-dev +codelayer-dev: wui-dev + # Show current dev environment setup .PHONY: dev-status dev-status: diff --git a/homebrew-humanlayer/Casks/codelayer.rb b/homebrew-humanlayer/Casks/codelayer.rb new file mode 100644 index 0000000..b6e591a --- /dev/null +++ b/homebrew-humanlayer/Casks/codelayer.rb @@ -0,0 +1,32 @@ +cask "codelayer" do + version "0.1.0" + sha256 "YOUR_SHA256_HERE" # Update after building DMG + + url "https://github.com/humanlayer/humanlayer/releases/download/v#{version}/HumanLayer-#{version}-darwin-arm64.dmg", + verified: "github.com/humanlayer/humanlayer/" + + name "CodeLayer" + desc "Desktop application for managing AI agent approvals and sessions" + homepage "https://humanlayer.dev/" + + livecheck do + url :url + strategy :github_latest + end + + app "HumanLayer.app" + + # Create symlinks for bundled binaries in PATH + # These binaries are located in the app bundle at Contents/Resources/bin/ + binary "#{appdir}/HumanLayer.app/Contents/Resources/bin/humanlayer" + binary "#{appdir}/HumanLayer.app/Contents/Resources/bin/hld", target: "hld" + + zap trash: [ + "~/Library/Application Support/HumanLayer", + "~/Library/Preferences/dev.humanlayer.codelayer.plist", + "~/Library/Saved Application State/dev.humanlayer.codelayer.savedState", + "~/.humanlayer/codelayer*.json", + "~/.humanlayer/daemon*.db", + "~/.humanlayer/daemon*.sock", + ] +end \ No newline at end of file diff --git a/homebrew-humanlayer/README.md b/homebrew-humanlayer/README.md new file mode 100644 index 0000000..9c0effc --- /dev/null +++ b/homebrew-humanlayer/README.md @@ -0,0 +1,105 @@ +# HumanLayer Homebrew Tap + +This is a public Homebrew tap for easy installation of HumanLayer tools. No authentication is required. + +## Installation + +```bash +# Add the public tap +brew tap humanlayer/humanlayer + +# Install with --no-quarantine to bypass Gatekeeper +brew install --cask --no-quarantine humanlayer/humanlayer/codelayer +``` + +## PATH Configuration + +Homebrew automatically handles PATH setup by creating symlinks for the bundled binaries: +- `/usr/local/bin/humanlayer` → `/Applications/HumanLayer.app/Contents/Resources/bin/humanlayer` +- `/usr/local/bin/hld` → `/Applications/HumanLayer.app/Contents/Resources/bin/hld` + +This means: +- Claude Code can execute `humanlayer mcp claude_approvals` directly (no npx needed) +- Power users can run `hld` commands if needed +- No manual PATH configuration required + +## Verifying PATH Setup + +After installation, verify the binaries are accessible: + +```bash +# Check if humanlayer is in PATH +which humanlayer +# Should output: /usr/local/bin/humanlayer + +# Verify it's a symlink to the app bundle +ls -la /usr/local/bin/humanlayer +# Should show: /usr/local/bin/humanlayer -> /Applications/HumanLayer.app/Contents/Resources/bin/humanlayer + +# Test the binary works +humanlayer --version +``` + +## Updating + +```bash +brew update +brew upgrade --cask codelayer +``` + +When upgrading, Homebrew automatically updates the symlinks to point to the new app version. + +## Set --no-quarantine as default + +```bash +export HOMEBREW_CASK_OPTS="--no-quarantine" +``` + +## Troubleshooting PATH Issues + +### Binary not found after installation + +If `humanlayer` command is not found after installation: + +1. **Check if `/usr/local/bin` is in your PATH:** + ```bash + echo $PATH | grep -q "/usr/local/bin" && echo "Path is correct" || echo "Path needs updating" + ``` + +2. **If PATH needs updating, add to your shell profile:** + ```bash + # For zsh (default on macOS): + echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.zshrc + source ~/.zshrc + + # For bash: + echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bash_profile + source ~/.bash_profile + ``` + +3. **Verify Homebrew created the symlinks:** + ```bash + ls -la /usr/local/bin/ | grep -E "humanlayer|hld" + ``` + +4. **If symlinks are missing, reinstall the cask:** + ```bash + brew uninstall --cask codelayer + brew install --cask --no-quarantine humanlayer/humanlayer/codelayer + ``` + +### Claude Code can't find humanlayer + +If Claude Code shows errors about `humanlayer` not being found: + +1. **Ensure Claude Code was restarted after installation** +2. **Check Claude Code's PATH environment:** + - Claude Code inherits PATH from how it was launched + - If launched from Dock/Spotlight, it may have a limited PATH + - Try launching Claude Code from Terminal: `open -a "Claude Code"` + +### Development vs Production Binaries + +- **Production**: Uses bundled binaries from `/Applications/HumanLayer.app/Contents/Resources/bin/` +- **Development**: Should use globally installed `humanlayer` (via npm/bun) +- **Never mix**: Don't try to use production binaries in development \ No newline at end of file diff --git a/humanlayer-wui/README.md b/humanlayer-wui/README.md index 523ef42..32c7a0c 100644 --- a/humanlayer-wui/README.md +++ b/humanlayer-wui/README.md @@ -4,17 +4,106 @@ Web/desktop UI for the HumanLayer daemon (`hld`) built with Tauri and React. ## Development +### Running in Development Mode + +1. Build the daemon (required for auto-launch): + + ```bash + make daemon-dev-build + ``` + +2. Start CodeLayer in development mode: + ```bash + make codelayer-dev + ``` + +The daemon starts automatically and invisibly when the app launches. No manual daemon management needed. + +### Disabling Auto-Launch (Advanced Users) + +If you prefer to manage the daemon manually: + ```bash -# Install dependencies -bun install - -# Start dev server -bun run tauri dev - -# Build for production -bun run build +export HUMANLAYER_WUI_AUTOLAUNCH_DAEMON=false +make codelayer-dev ``` +### Using an External Daemon + +To connect to a daemon running on a specific port: + +```bash +export HUMANLAYER_DAEMON_HTTP_PORT=7777 +make codelayer-dev +``` + +### Building for Production + +To build CodeLayer with bundled daemon: + +```bash +make codelayer-bundle +``` + +This will: + +1. Build the daemon for macOS ARM64 +2. Build the humanlayer CLI for macOS ARM64 +3. Copy both to the Tauri resources +4. Build CodeLayer with the bundled binaries + +The resulting DMG will include both binaries and automatically manage their lifecycle. + +### Daemon Management + +The daemon lifecycle is completely automatic: + +**In development mode:** + +- Daemon starts invisibly when CodeLayer launches +- Each git branch gets its own daemon instance +- Database is copied from `daemon-dev.db` to `daemon-{branch}.db` +- Socket and port are isolated per branch +- Use debug panel (bottom-left settings icon) for manual control if needed + +**In production mode:** + +- Daemon starts invisibly when CodeLayer launches +- Uses default paths (`~/.humanlayer/daemon.db`) +- Stops automatically when the app exits +- No user interaction or awareness required + +**Error Handling:** + +- If daemon fails to start, app continues normally +- Connection can be established later via debug panel (dev) or automatically on retry +- All errors are logged but never interrupt the user experience + +### MCP Testing + +To test MCP functionality: + +**In development:** + +- Ensure you have `humanlayer` installed globally: `npm install -g humanlayer` +- Start CodeLayer: `make codelayer-dev` +- Configure Claude Code to use `humanlayer mcp claude_approvals` +- The MCP server will connect to your running daemon + +**In production (after Homebrew installation):** + +- Claude Code can directly execute `humanlayer mcp claude_approvals` +- No npm or npx required - Homebrew automatically created symlinks in PATH +- The MCP server connects to the daemon started by CodeLayer +- Verify PATH setup is working: `which humanlayer` should show `/usr/local/bin/humanlayer` + +**Troubleshooting MCP connection:** + +- If MCP can't find `humanlayer`, restart Claude Code after installation +- If launched from Dock, Claude Code may have limited PATH - launch from Terminal instead +- Check daemon is running: `ps aux | grep hld` +- Check MCP logs in Claude Code for connection errors + ## Quick Start for Frontend Development Always use React hooks, never the daemon client directly: From e878263d44e5935c5731faecc67746748ade0bf7 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 11:01:48 -0700 Subject: [PATCH 07/13] formatting --- homebrew-humanlayer/Casks/codelayer.rb | 4 ++-- homebrew-humanlayer/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homebrew-humanlayer/Casks/codelayer.rb b/homebrew-humanlayer/Casks/codelayer.rb index b6e591a..5b59cf3 100644 --- a/homebrew-humanlayer/Casks/codelayer.rb +++ b/homebrew-humanlayer/Casks/codelayer.rb @@ -4,7 +4,7 @@ cask "codelayer" do url "https://github.com/humanlayer/humanlayer/releases/download/v#{version}/HumanLayer-#{version}-darwin-arm64.dmg", verified: "github.com/humanlayer/humanlayer/" - + name "CodeLayer" desc "Desktop application for managing AI agent approvals and sessions" homepage "https://humanlayer.dev/" @@ -29,4 +29,4 @@ cask "codelayer" do "~/.humanlayer/daemon*.db", "~/.humanlayer/daemon*.sock", ] -end \ No newline at end of file +end diff --git a/homebrew-humanlayer/README.md b/homebrew-humanlayer/README.md index 9c0effc..41e9037 100644 --- a/homebrew-humanlayer/README.md +++ b/homebrew-humanlayer/README.md @@ -71,7 +71,7 @@ If `humanlayer` command is not found after installation: # For zsh (default on macOS): echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.zshrc source ~/.zshrc - + # For bash: echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bash_profile source ~/.bash_profile @@ -102,4 +102,4 @@ If Claude Code shows errors about `humanlayer` not being found: - **Production**: Uses bundled binaries from `/Applications/HumanLayer.app/Contents/Resources/bin/` - **Development**: Should use globally installed `humanlayer` (via npm/bun) -- **Never mix**: Don't try to use production binaries in development \ No newline at end of file +- **Never mix**: Don't try to use production binaries in development From c4d3f289ef55878e056a9892f513f8ba90d4da12 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 11:46:55 -0700 Subject: [PATCH 08/13] makefile to build daemon too (when run wui on dev) --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index e37511c..1ab7ba7 100644 --- a/Makefile +++ b/Makefile @@ -617,9 +617,9 @@ wui-dev: echo "$(TIMESTAMP) starting dev wui in $$(pwd)" > ~/.humanlayer/logs/wui-dev-$(TIMESTAMP).log cd humanlayer-wui && HUMANLAYER_DAEMON_SOCKET=~/.humanlayer/daemon-dev.sock bun run tauri dev 2>&1 | tee -a ~/.humanlayer/logs/wui-dev-$(TIMESTAMP).log -# Alias for wui-dev +# Alias for wui-dev that ensures daemon is built first .PHONY: codelayer-dev -codelayer-dev: wui-dev +codelayer-dev: daemon-dev-build wui-dev # Show current dev environment setup .PHONY: dev-status From 3e715964b3b88e1a223affd9cbf4f9c398c97309 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 11:56:20 -0700 Subject: [PATCH 09/13] refactor: improve daemon management in WUI with async/await and better error handling - Convert daemon start/stop operations to async/await pattern - Switch HTTP host from 127.0.0.1 to localhost for better compatibility - Add debug URL override support for development - Improve error handling and startup behavior - Fix path resolution for development mode when running from src-tauri --- humanlayer-wui/src-tauri/src/daemon.rs | 131 ++++++++++++------ humanlayer-wui/src-tauri/src/lib.rs | 68 ++++++--- humanlayer-wui/src/lib/daemon/http-config.ts | 9 +- humanlayer-wui/src/services/daemon-service.ts | 4 +- 4 files changed, 142 insertions(+), 70 deletions(-) diff --git a/humanlayer-wui/src-tauri/src/daemon.rs b/humanlayer-wui/src-tauri/src/daemon.rs index 14f0e94..8c02a7d 100644 --- a/humanlayer-wui/src-tauri/src/daemon.rs +++ b/humanlayer-wui/src-tauri/src/daemon.rs @@ -1,11 +1,11 @@ -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; +use serde::{Deserialize, Serialize}; use std::env; -use std::path::PathBuf; use std::fs; use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Manager}; -use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DaemonInfo { @@ -31,18 +31,19 @@ impl DaemonManager { } } - pub fn start_daemon( + pub async fn start_daemon( &self, app_handle: &AppHandle, is_dev: bool, - branch_override: Option + branch_override: Option, ) -> Result { - let mut process = self.process.lock().unwrap(); - // Check if already running - if process.is_some() { - if let Some(info) = self.info.lock().unwrap().as_ref() { - return Ok(info.clone()); + { + let process = self.process.lock().unwrap(); + if process.is_some() { + if let Some(info) = self.info.lock().unwrap().as_ref() { + return Ok(info.clone()); + } } } @@ -50,7 +51,7 @@ impl DaemonManager { if let Ok(port_str) = env::var("HUMANLAYER_DAEMON_HTTP_PORT") { if let Ok(port) = port_str.parse::() { // Try to connect to existing daemon - if check_daemon_health(port).is_ok() { + if check_daemon_health(port).await.is_ok() { let info = DaemonInfo { port, pid: 0, // Unknown PID for external daemon @@ -67,7 +68,8 @@ impl DaemonManager { // Determine branch identifier let branch_id = if is_dev { - branch_override.or_else(get_git_branch) + branch_override + .or_else(get_git_branch) .unwrap_or_else(|| "dev".to_string()) } else { "production".to_string() @@ -77,8 +79,7 @@ impl DaemonManager { let branch_id = extract_ticket_id(&branch_id).unwrap_or(branch_id); // Set up paths - let home_dir = dirs::home_dir() - .ok_or("Failed to get home directory")?; + let home_dir = dirs::home_dir().ok_or("Failed to get home directory")?; let humanlayer_dir = home_dir.join(".humanlayer"); fs::create_dir_all(&humanlayer_dir) .map_err(|e| format!("Failed to create .humanlayer directory: {e}"))?; @@ -111,26 +112,41 @@ impl DaemonManager { // Build environment with port 0 for dynamic allocation let mut env_vars = env::vars().collect::>(); - env_vars.push(("HUMANLAYER_DATABASE_PATH".to_string(), database_path.to_str().unwrap().to_string())); - env_vars.push(("HUMANLAYER_DAEMON_SOCKET".to_string(), socket_path.to_str().unwrap().to_string())); + env_vars.push(( + "HUMANLAYER_DATABASE_PATH".to_string(), + database_path.to_str().unwrap().to_string(), + )); + env_vars.push(( + "HUMANLAYER_DAEMON_SOCKET".to_string(), + socket_path.to_str().unwrap().to_string(), + )); env_vars.push(("HUMANLAYER_DAEMON_HTTP_PORT".to_string(), "0".to_string())); - env_vars.push(("HUMANLAYER_DAEMON_HTTP_HOST".to_string(), "127.0.0.1".to_string())); + env_vars.push(( + "HUMANLAYER_DAEMON_HTTP_HOST".to_string(), + "localhost".to_string(), + )); if is_dev { - env_vars.push(("HUMANLAYER_DAEMON_VERSION_OVERRIDE".to_string(), branch_id.clone())); + env_vars.push(( + "HUMANLAYER_DAEMON_VERSION_OVERRIDE".to_string(), + branch_id.clone(), + )); } // Start daemon with stdout capture let mut cmd = Command::new(&daemon_path); cmd.envs(env_vars) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); // Let stderr go to console for debugging + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); // Let stderr go to console for debugging - let mut child = cmd.spawn() + let mut child = cmd + .spawn() .map_err(|e| format!("Failed to start daemon: {e}"))?; // Parse stdout to get the actual port - let stdout = child.stdout.take() + let stdout = child + .stdout + .take() .ok_or("Failed to capture daemon stdout")?; let reader = BufReader::new(stdout); @@ -138,9 +154,7 @@ impl DaemonManager { for line in reader.lines().map_while(Result::ok) { if line.starts_with("HTTP_PORT=") { - actual_port = line.replace("HTTP_PORT=", "") - .parse::() - .ok(); + actual_port = line.replace("HTTP_PORT=", "").parse::().ok(); break; } } @@ -156,11 +170,15 @@ impl DaemonManager { is_running: true, }; - *process = Some(child); + // Store the process and info before awaiting + { + let mut process = self.process.lock().unwrap(); + *process = Some(child); + } *self.info.lock().unwrap() = Some(daemon_info.clone()); // Wait for daemon to be ready - wait_for_daemon(port)?; + wait_for_daemon(port).await?; Ok(daemon_info) } @@ -169,7 +187,8 @@ impl DaemonManager { let mut process = self.process.lock().unwrap(); if let Some(mut child) = process.take() { - child.kill() + child + .kill() .map_err(|e| format!("Failed to stop daemon: {e}"))?; // Wait for process to exit @@ -193,8 +212,8 @@ impl DaemonManager { if let Some(child) = process.as_mut() { // Check if process is still alive match child.try_wait() { - Ok(None) => true, // Still running - _ => false, // Exited or error + Ok(None) => true, // Still running + _ => false, // Exited or error } } else { false @@ -205,21 +224,37 @@ impl DaemonManager { fn get_daemon_path(app_handle: &AppHandle, is_dev: bool) -> Result { if is_dev { // In dev mode, look for hld-dev in the project - let dev_path = env::current_dir() - .map_err(|e| format!("Failed to get current directory: {e}"))? - .parent() // Go up from humanlayer-wui to humanlayer - .ok_or("Failed to get parent directory")? - .join("hld") - .join("hld-dev"); + let current = + env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?; + + // Handle both running from src-tauri and from humanlayer-wui + let dev_path = if current.ends_with("src-tauri") { + current + .parent() // humanlayer-wui + .and_then(|p| p.parent()) // humanlayer root + .ok_or("Failed to get parent directory")? + .join("hld") + .join("hld-dev") + } else { + current + .parent() // Go up from humanlayer-wui to humanlayer + .ok_or("Failed to get parent directory")? + .join("hld") + .join("hld-dev") + }; if dev_path.exists() { Ok(dev_path) } else { - Err("Development daemon not found. Run 'make daemon-dev-build' first.".to_string()) + Err(format!( + "Development daemon not found at {:?}. Run 'make daemon-dev-build' first.", + dev_path + )) } } else { // In production, use bundled binary - let resource_dir = app_handle.path() + let resource_dir = app_handle + .path() .resource_dir() .map_err(|e| format!("Failed to get resource directory: {e}"))?; @@ -234,7 +269,8 @@ fn get_git_branch() -> Option { .ok() .and_then(|output| { if output.status.success() { - String::from_utf8(output.stdout).ok() + String::from_utf8(output.stdout) + .ok() .map(|s| s.trim().to_string()) } else { None @@ -250,24 +286,29 @@ fn extract_ticket_id(branch: &str) -> Option { .map(|m| m.as_str().to_string()) } -fn check_daemon_health(port: u16) -> Result<(), String> { - match reqwest::blocking::get(format!("http://127.0.0.1:{port}/api/v1/health")) { +async fn check_daemon_health(port: u16) -> Result<(), String> { + let client = reqwest::Client::new(); + match client + .get(format!("http://localhost:{port}/api/v1/health")) + .send() + .await + { Ok(response) if response.status().is_success() => Ok(()), Ok(_) => Err("Daemon health check failed".to_string()), Err(e) => Err(format!("Failed to connect to daemon: {e}")), } } -fn wait_for_daemon(port: u16) -> Result<(), String> { +async fn wait_for_daemon(port: u16) -> Result<(), String> { let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(10); while start.elapsed() < timeout { - if check_daemon_health(port).is_ok() { + if check_daemon_health(port).await.is_ok() { return Ok(()); } - std::thread::sleep(std::time::Duration::from_millis(500)); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; } Err("Daemon failed to start within 10 seconds".to_string()) diff --git a/humanlayer-wui/src-tauri/src/lib.rs b/humanlayer-wui/src-tauri/src/lib.rs index 5b3d22e..de5bd5e 100644 --- a/humanlayer-wui/src-tauri/src/lib.rs +++ b/humanlayer-wui/src-tauri/src/lib.rs @@ -1,10 +1,10 @@ mod daemon; -use daemon::{DaemonManager, DaemonInfo}; -use tauri::{Manager, State}; -use tauri_plugin_store::StoreExt; +use daemon::{DaemonInfo, DaemonManager}; use std::env; use std::path::PathBuf; +use tauri::{Manager, State}; +use tauri_plugin_store::StoreExt; // Helper to get store path based on dev mode and branch fn get_store_path(is_dev: bool, branch_id: Option<&str>) -> PathBuf { @@ -30,15 +30,19 @@ async fn start_daemon( is_dev: bool, branch_override: Option, ) -> Result { - let info = daemon_manager.start_daemon(&app_handle, is_dev, branch_override)?; + let info = daemon_manager + .start_daemon(&app_handle, is_dev, branch_override) + .await?; // Save to store using branch-specific path in dev mode let store_path = get_store_path(is_dev, Some(&info.branch_id)); - let store = app_handle.store(&store_path) + let store = app_handle + .store(&store_path) .map_err(|e| format!("Failed to access store: {e}"))?; store.set("current_daemon", serde_json::to_value(&info).unwrap()); - store.save() + store + .save() .map_err(|e| format!("Failed to save store: {e}"))?; Ok(info) @@ -58,15 +62,22 @@ async fn stop_daemon( daemon_manager.stop_daemon()?; // Update store to mark as not running - let store = app_handle.store(&store_path) + let store = app_handle + .store(&store_path) .map_err(|e| format!("Failed to access store: {e}"))?; // Get the stored daemon info and update is_running - if let Some(mut stored_info) = store.get("current_daemon") - .and_then(|v| serde_json::from_value::(v).ok()) { + if let Some(mut stored_info) = store + .get("current_daemon") + .and_then(|v| serde_json::from_value::(v).ok()) + { stored_info.is_running = false; - store.set("current_daemon", serde_json::to_value(&stored_info).unwrap()); - store.save() + store.set( + "current_daemon", + serde_json::to_value(&stored_info).unwrap(), + ); + store + .save() .map_err(|e| format!("Failed to save store: {e}"))?; } } else { @@ -89,10 +100,12 @@ async fn get_daemon_info( // Otherwise check store for last known daemon let store_path = get_store_path(is_dev, None); - let store = app_handle.store(&store_path) + let store = app_handle + .store(&store_path) .map_err(|e| format!("Failed to access store: {e}"))?; - let stored_info = store.get("current_daemon") + let stored_info = store + .get("current_daemon") .and_then(|v| serde_json::from_value::(v).ok()); Ok(stored_info) @@ -103,7 +116,6 @@ async fn is_daemon_running(daemon_manager: State<'_, DaemonManager>) -> Result { tracing::info!("Daemon started automatically on port {}", info.port); } @@ -146,10 +161,16 @@ pub fn run() { // Common edge cases that shouldn't interrupt the user if e.contains("port") || e.contains("already in use") { tracing::info!("Port conflict detected, user can connect manually"); - } else if e.contains("binary not found") || e.contains("daemon not found") { - tracing::warn!("Daemon binary missing, this is expected in some dev scenarios"); + } else if e.contains("binary not found") + || e.contains("daemon not found") + { + tracing::warn!( + "Daemon binary missing, this is expected in some dev scenarios" + ); } else if e.contains("permission") { - tracing::error!("Permission issue starting daemon, user intervention required"); + tracing::error!( + "Permission issue starting daemon, user intervention required" + ); } } } @@ -171,10 +192,15 @@ pub fn run() { if let Ok(store) = window.app_handle().store(&store_path) { // Update store to mark daemon as not running - if let Some(mut stored_info) = store.get("current_daemon") - .and_then(|v| serde_json::from_value::(v).ok()) { + if let Some(mut stored_info) = store + .get("current_daemon") + .and_then(|v| serde_json::from_value::(v).ok()) + { stored_info.is_running = false; - store.set("current_daemon", serde_json::to_value(&stored_info).unwrap()); + store.set( + "current_daemon", + serde_json::to_value(&stored_info).unwrap(), + ); let _ = store.save(); } } diff --git a/humanlayer-wui/src/lib/daemon/http-config.ts b/humanlayer-wui/src/lib/daemon/http-config.ts index 31913c6..1533d07 100644 --- a/humanlayer-wui/src/lib/daemon/http-config.ts +++ b/humanlayer-wui/src/lib/daemon/http-config.ts @@ -2,7 +2,12 @@ import { daemonService } from '@/services/daemon-service' // Get daemon URL from environment or managed daemon export async function getDaemonUrl(): Promise { - // Check for explicit URL first + // Check for custom URL from debug panel first + if ((window as any).__HUMANLAYER_DAEMON_URL) { + return (window as any).__HUMANLAYER_DAEMON_URL + } + + // Check for explicit URL from environment if (import.meta.env.VITE_HUMANLAYER_DAEMON_URL) { return import.meta.env.VITE_HUMANLAYER_DAEMON_URL } @@ -11,7 +16,7 @@ export async function getDaemonUrl(): Promise { try { const daemonInfo = await daemonService.getDaemonInfo() if (daemonInfo && daemonInfo.port) { - return `http://127.0.0.1:${daemonInfo.port}` + return `http://localhost:${daemonInfo.port}` } } catch (error) { console.warn('Failed to get managed daemon info:', error) diff --git a/humanlayer-wui/src/services/daemon-service.ts b/humanlayer-wui/src/services/daemon-service.ts index af3b94c..7fea00a 100644 --- a/humanlayer-wui/src/services/daemon-service.ts +++ b/humanlayer-wui/src/services/daemon-service.ts @@ -42,9 +42,9 @@ class DaemonService { } } - async getDaemonInfo(): Promise { + async getDaemonInfo(isDev: boolean = import.meta.env.DEV): Promise { try { - return await invoke('get_daemon_info') + return await invoke('get_daemon_info', { isDev }) } catch (error) { console.error('Failed to get daemon info:', error) return null From bcc7a9eec364121697f3459b23ef36170d0df8ee Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 13:01:08 -0700 Subject: [PATCH 10/13] daemon no longer randomly crashes Needed to do weird stdout things --- humanlayer-wui/src-tauri/src/daemon.rs | 138 +++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 11 deletions(-) diff --git a/humanlayer-wui/src-tauri/src/daemon.rs b/humanlayer-wui/src-tauri/src/daemon.rs index 8c02a7d..802f593 100644 --- a/humanlayer-wui/src-tauri/src/daemon.rs +++ b/humanlayer-wui/src-tauri/src/daemon.rs @@ -131,17 +131,59 @@ impl DaemonManager { "HUMANLAYER_DAEMON_VERSION_OVERRIDE".to_string(), branch_id.clone(), )); + // Enable debug logging for daemon in dev mode + env_vars.push(( + "HUMANLAYER_DEBUG".to_string(), + "true".to_string(), + )); + env_vars.push(( + "GIN_MODE".to_string(), + "debug".to_string(), + )); } - // Start daemon with stdout capture + // Start daemon with stdout capture and stderr logging let mut cmd = Command::new(&daemon_path); - cmd.envs(env_vars) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); // Let stderr go to console for debugging + + // In dev mode, capture stderr to see what's happening + if is_dev { + cmd.envs(env_vars) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + } else { + cmd.envs(env_vars) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + } let mut child = cmd .spawn() .map_err(|e| format!("Failed to start daemon: {e}"))?; + + // Get the PID before we do anything else + let pid = child.id(); + tracing::info!("Daemon spawned with PID: {}", pid); + + // If in dev mode, spawn a task to read stderr + if is_dev { + if let Some(stderr) = child.stderr.take() { + let branch_id_clone = branch_id.clone(); + tokio::spawn(async move { + let reader = BufReader::new(stderr); + for line in reader.lines() { + match line { + Ok(line) => { + tracing::error!("[Daemon stderr] {}: {}", branch_id_clone, line); + } + Err(e) => { + tracing::error!("Error reading daemon stderr: {}", e); + break; + } + } + } + }); + } + } // Parse stdout to get the actual port let stdout = child @@ -149,21 +191,52 @@ impl DaemonManager { .take() .ok_or("Failed to capture daemon stdout")?; - let reader = BufReader::new(stdout); + let mut reader = BufReader::new(stdout); let mut actual_port = None; + let mut first_line = String::new(); - for line in reader.lines().map_while(Result::ok) { - if line.starts_with("HTTP_PORT=") { - actual_port = line.replace("HTTP_PORT=", "").parse::().ok(); - break; - } + // Read the first line synchronously to get the port + reader + .read_line(&mut first_line) + .map_err(|e| format!("Failed to read daemon stdout: {}", e))?; + + if first_line.starts_with("HTTP_PORT=") { + actual_port = first_line.trim().replace("HTTP_PORT=", "").parse::().ok(); } let port = actual_port.ok_or("Daemon failed to report port")?; + tracing::info!("Got port {} from daemon stdout", port); + + // Now spawn a task to keep reading stdout to prevent SIGPIPE + let branch_id_for_stdout = branch_id.clone(); + tokio::spawn(async move { + // Continue reading the rest of stdout + for line in reader.lines() { + match line { + Ok(line) => { + tracing::trace!("[Daemon stdout] {}: {}", branch_id_for_stdout, line); + } + Err(e) => { + tracing::debug!("Daemon stdout closed for {}: {}", branch_id_for_stdout, e); + break; + } + } + } + tracing::debug!("Stdout reader task finished for {}", branch_id_for_stdout); + }); + + // Check if process is still alive after reading port + match child.try_wait() { + Ok(None) => tracing::info!("Daemon process still running after port read"), + Ok(Some(status)) => { + return Err(format!("Daemon process exited immediately after starting! Status: {:?}", status)); + } + Err(e) => tracing::error!("Error checking daemon status: {}", e), + } let daemon_info = DaemonInfo { port, - pid: child.id(), + pid, database_path: database_path.to_str().unwrap().to_string(), socket_path: socket_path.to_str().unwrap().to_string(), branch_id: branch_id.clone(), @@ -178,7 +251,50 @@ impl DaemonManager { *self.info.lock().unwrap() = Some(daemon_info.clone()); // Wait for daemon to be ready + tracing::info!("Waiting for daemon to be ready on port {}", port); wait_for_daemon(port).await?; + tracing::info!("Daemon is ready and responding to health checks"); + + // Spawn a task to monitor the daemon process + let process_arc = self.process.clone(); + let branch_id_clone = branch_id.clone(); + let port_for_monitor = port; + tokio::spawn(async move { + let mut last_check = std::time::Instant::now(); + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + let mut process_guard = process_arc.lock().unwrap(); + if let Some(child) = process_guard.as_mut() { + match child.try_wait() { + Ok(Some(status)) => { + // Process has exited + tracing::error!( + "Daemon process exited unexpectedly! Branch: {}, Port: {}, Exit status: {:?}, Time since last check: {:?}", + branch_id_clone, + port_for_monitor, + status, + last_check.elapsed() + ); + // Clear the process + *process_guard = None; + break; + } + Ok(None) => { + // Still running + last_check = std::time::Instant::now(); + } + Err(e) => { + tracing::error!("Error checking daemon process status: {}", e); + break; + } + } + } else { + // No process to monitor + break; + } + } + }); Ok(daemon_info) } From 210d22a836ff69e0097acb20cb138985d55eefb6 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 13:40:31 -0700 Subject: [PATCH 11/13] Parse daemon log levels to improve dev logging output Previously all daemon stderr output was logged as ERROR level, causing confusion and double timestamps. Now we parse slog format to extract actual log levels (INFO, WARN, ERROR, etc.) and route them to appropriate tracing levels. --- humanlayer-wui/src-tauri/src/daemon.rs | 79 ++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/humanlayer-wui/src-tauri/src/daemon.rs b/humanlayer-wui/src-tauri/src/daemon.rs index 802f593..3da2ceb 100644 --- a/humanlayer-wui/src-tauri/src/daemon.rs +++ b/humanlayer-wui/src-tauri/src/daemon.rs @@ -144,7 +144,7 @@ impl DaemonManager { // Start daemon with stdout capture and stderr logging let mut cmd = Command::new(&daemon_path); - + // In dev mode, capture stderr to see what's happening if is_dev { cmd.envs(env_vars) @@ -159,11 +159,11 @@ impl DaemonManager { let mut child = cmd .spawn() .map_err(|e| format!("Failed to start daemon: {e}"))?; - + // Get the PID before we do anything else let pid = child.id(); tracing::info!("Daemon spawned with PID: {}", pid); - + // If in dev mode, spawn a task to read stderr if is_dev { if let Some(stderr) = child.stderr.take() { @@ -173,7 +173,17 @@ impl DaemonManager { for line in reader.lines() { match line { Ok(line) => { - tracing::error!("[Daemon stderr] {}: {}", branch_id_clone, line); + // Parse slog format to extract level + let level = extract_log_level(&line); + let cleaned_line = remove_timestamp(&line); + + match level { + LogLevel::Error => tracing::error!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + LogLevel::Warn => tracing::warn!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + LogLevel::Info => tracing::info!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + LogLevel::Debug => tracing::debug!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + LogLevel::Trace => tracing::trace!("[Daemon] {}: {}", branch_id_clone, cleaned_line), + } } Err(e) => { tracing::error!("Error reading daemon stderr: {}", e); @@ -198,8 +208,8 @@ impl DaemonManager { // Read the first line synchronously to get the port reader .read_line(&mut first_line) - .map_err(|e| format!("Failed to read daemon stdout: {}", e))?; - + .map_err(|e| format!("Failed to read daemon stdout: {e}"))?; + if first_line.starts_with("HTTP_PORT=") { actual_port = first_line.trim().replace("HTTP_PORT=", "").parse::().ok(); } @@ -224,12 +234,12 @@ impl DaemonManager { } tracing::debug!("Stdout reader task finished for {}", branch_id_for_stdout); }); - + // Check if process is still alive after reading port match child.try_wait() { Ok(None) => tracing::info!("Daemon process still running after port read"), Ok(Some(status)) => { - return Err(format!("Daemon process exited immediately after starting! Status: {:?}", status)); + return Err(format!("Daemon process exited immediately after starting! Status: {status:?}")); } Err(e) => tracing::error!("Error checking daemon status: {}", e), } @@ -263,7 +273,7 @@ impl DaemonManager { let mut last_check = std::time::Instant::now(); loop { tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - + let mut process_guard = process_arc.lock().unwrap(); if let Some(child) = process_guard.as_mut() { match child.try_wait() { @@ -363,8 +373,7 @@ fn get_daemon_path(app_handle: &AppHandle, is_dev: bool) -> Result Result<(), String> { Err("Daemon failed to start within 10 seconds".to_string()) } + +#[derive(Debug)] +enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +fn extract_log_level(line: &str) -> LogLevel { + // slog format includes level like: "2024-01-30T10:15:30Z INFO message" + // or sometimes: "INFO[0001] message" for other loggers + + if line.contains(" ERROR ") || line.contains("ERROR[") { + LogLevel::Error + } else if line.contains(" WARN ") || line.contains("WARN[") { + LogLevel::Warn + } else if line.contains(" INFO ") || line.contains("INFO[") { + LogLevel::Info + } else if line.contains(" DEBUG ") || line.contains("DEBUG[") { + LogLevel::Debug + } else if line.contains(" TRACE ") || line.contains("TRACE[") { + LogLevel::Trace + } else { + // Default to info for unparseable lines + LogLevel::Info + } +} + +fn remove_timestamp(line: &str) -> &str { + // Remove slog timestamp to avoid double timestamps + // Format: "2024-01-30T10:15:30Z LEVEL message" + if let Some(idx) = line.find(" INFO ") { + &line[idx + 6..] + } else if let Some(idx) = line.find(" ERROR ") { + &line[idx + 7..] + } else if let Some(idx) = line.find(" WARN ") { + &line[idx + 6..] + } else if let Some(idx) = line.find(" DEBUG ") { + &line[idx + 7..] + } else if let Some(idx) = line.find(" TRACE ") { + &line[idx + 7..] + } else { + // Return original if no timestamp found + line + } +} From 1d67e2071a5764a90bc646d9cb74187265ac2239 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 13:40:50 -0700 Subject: [PATCH 12/13] Integrate debug panel into status bar with theme-aware colors - Move debug panel button from floating bottom-left to status bar bottom-right - Replace Settings icon with Bug icon and match styling of other status controls - Update connection status colors to use CSS variables for theme consistency - Refactor DebugPanel to accept open/onOpenChange props for controlled state --- humanlayer-wui/src/components/DebugPanel.tsx | 23 ++++++++------------ humanlayer-wui/src/components/Layout.tsx | 20 +++++++++++++++-- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/humanlayer-wui/src/components/DebugPanel.tsx b/humanlayer-wui/src/components/DebugPanel.tsx index cb88e22..2c6552c 100644 --- a/humanlayer-wui/src/components/DebugPanel.tsx +++ b/humanlayer-wui/src/components/DebugPanel.tsx @@ -8,14 +8,18 @@ import { DialogDescription, DialogHeader, DialogTitle, - DialogTrigger, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Loader2, RefreshCw, Link, Settings, Server } from 'lucide-react' +import { Loader2, RefreshCw, Link, Server } from 'lucide-react' import { useDaemonConnection } from '@/hooks/useDaemonConnection' -export function DebugPanel() { +interface DebugPanelProps { + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export function DebugPanel({ open, onOpenChange }: DebugPanelProps) { const { connected, reconnect } = useDaemonConnection() const [isRestarting, setIsRestarting] = useState(false) const [restartError, setRestartError] = useState(null) @@ -100,16 +104,7 @@ export function DebugPanel() { } return ( - - - - + Debug Panel @@ -124,7 +119,7 @@ export function DebugPanel() {
Connection {connected ? 'Connected' : 'Disconnected'} diff --git a/humanlayer-wui/src/components/Layout.tsx b/humanlayer-wui/src/components/Layout.tsx index a460a73..b6673fe 100644 --- a/humanlayer-wui/src/components/Layout.tsx +++ b/humanlayer-wui/src/components/Layout.tsx @@ -15,7 +15,7 @@ import { Toaster } from 'sonner' import { notificationService } from '@/services/NotificationService' import { useTheme } from '@/contexts/ThemeContext' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { MessageCircle } from 'lucide-react' +import { MessageCircle, Bug } from 'lucide-react' import { openUrl } from '@tauri-apps/plugin-opener' import { DebugPanel } from '@/components/DebugPanel' import '@/App.css' @@ -24,6 +24,7 @@ export function Layout() { const [approvals, setApprovals] = useState([]) const [activeSessionId] = useState(null) const { setTheme } = useTheme() + const [isDebugPanelOpen, setIsDebugPanelOpen] = useState(false) // Use the daemon connection hook for all connection management const { connected, connecting, version, connect } = useDaemonConnection() @@ -241,6 +242,21 @@ export function Layout() {

Submit feedback (⌘⇧F)

+ {import.meta.env.DEV && ( + + + + + +

Debug Panel (Dev Only)

+
+
+ )}
@@ -267,7 +283,7 @@ export function Layout() { {/* Debug Panel */} - +
) } From 985c264b33e3a9f15e6c13c79aea799c14614b88 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 30 Jul 2025 13:45:10 -0700 Subject: [PATCH 13/13] Remove local homebrew tap files - moved to separate repository The homebrew tap is now properly maintained in its own repository at humanlayer/homebrew-humanlayer for public distribution. --- homebrew-humanlayer/Casks/codelayer.rb | 32 -------- homebrew-humanlayer/README.md | 105 ------------------------- 2 files changed, 137 deletions(-) delete mode 100644 homebrew-humanlayer/Casks/codelayer.rb delete mode 100644 homebrew-humanlayer/README.md diff --git a/homebrew-humanlayer/Casks/codelayer.rb b/homebrew-humanlayer/Casks/codelayer.rb deleted file mode 100644 index 5b59cf3..0000000 --- a/homebrew-humanlayer/Casks/codelayer.rb +++ /dev/null @@ -1,32 +0,0 @@ -cask "codelayer" do - version "0.1.0" - sha256 "YOUR_SHA256_HERE" # Update after building DMG - - url "https://github.com/humanlayer/humanlayer/releases/download/v#{version}/HumanLayer-#{version}-darwin-arm64.dmg", - verified: "github.com/humanlayer/humanlayer/" - - name "CodeLayer" - desc "Desktop application for managing AI agent approvals and sessions" - homepage "https://humanlayer.dev/" - - livecheck do - url :url - strategy :github_latest - end - - app "HumanLayer.app" - - # Create symlinks for bundled binaries in PATH - # These binaries are located in the app bundle at Contents/Resources/bin/ - binary "#{appdir}/HumanLayer.app/Contents/Resources/bin/humanlayer" - binary "#{appdir}/HumanLayer.app/Contents/Resources/bin/hld", target: "hld" - - zap trash: [ - "~/Library/Application Support/HumanLayer", - "~/Library/Preferences/dev.humanlayer.codelayer.plist", - "~/Library/Saved Application State/dev.humanlayer.codelayer.savedState", - "~/.humanlayer/codelayer*.json", - "~/.humanlayer/daemon*.db", - "~/.humanlayer/daemon*.sock", - ] -end diff --git a/homebrew-humanlayer/README.md b/homebrew-humanlayer/README.md deleted file mode 100644 index 41e9037..0000000 --- a/homebrew-humanlayer/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# HumanLayer Homebrew Tap - -This is a public Homebrew tap for easy installation of HumanLayer tools. No authentication is required. - -## Installation - -```bash -# Add the public tap -brew tap humanlayer/humanlayer - -# Install with --no-quarantine to bypass Gatekeeper -brew install --cask --no-quarantine humanlayer/humanlayer/codelayer -``` - -## PATH Configuration - -Homebrew automatically handles PATH setup by creating symlinks for the bundled binaries: -- `/usr/local/bin/humanlayer` → `/Applications/HumanLayer.app/Contents/Resources/bin/humanlayer` -- `/usr/local/bin/hld` → `/Applications/HumanLayer.app/Contents/Resources/bin/hld` - -This means: -- Claude Code can execute `humanlayer mcp claude_approvals` directly (no npx needed) -- Power users can run `hld` commands if needed -- No manual PATH configuration required - -## Verifying PATH Setup - -After installation, verify the binaries are accessible: - -```bash -# Check if humanlayer is in PATH -which humanlayer -# Should output: /usr/local/bin/humanlayer - -# Verify it's a symlink to the app bundle -ls -la /usr/local/bin/humanlayer -# Should show: /usr/local/bin/humanlayer -> /Applications/HumanLayer.app/Contents/Resources/bin/humanlayer - -# Test the binary works -humanlayer --version -``` - -## Updating - -```bash -brew update -brew upgrade --cask codelayer -``` - -When upgrading, Homebrew automatically updates the symlinks to point to the new app version. - -## Set --no-quarantine as default - -```bash -export HOMEBREW_CASK_OPTS="--no-quarantine" -``` - -## Troubleshooting PATH Issues - -### Binary not found after installation - -If `humanlayer` command is not found after installation: - -1. **Check if `/usr/local/bin` is in your PATH:** - ```bash - echo $PATH | grep -q "/usr/local/bin" && echo "Path is correct" || echo "Path needs updating" - ``` - -2. **If PATH needs updating, add to your shell profile:** - ```bash - # For zsh (default on macOS): - echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.zshrc - source ~/.zshrc - - # For bash: - echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bash_profile - source ~/.bash_profile - ``` - -3. **Verify Homebrew created the symlinks:** - ```bash - ls -la /usr/local/bin/ | grep -E "humanlayer|hld" - ``` - -4. **If symlinks are missing, reinstall the cask:** - ```bash - brew uninstall --cask codelayer - brew install --cask --no-quarantine humanlayer/humanlayer/codelayer - ``` - -### Claude Code can't find humanlayer - -If Claude Code shows errors about `humanlayer` not being found: - -1. **Ensure Claude Code was restarted after installation** -2. **Check Claude Code's PATH environment:** - - Claude Code inherits PATH from how it was launched - - If launched from Dock/Spotlight, it may have a limited PATH - - Try launching Claude Code from Terminal: `open -a "Claude Code"` - -### Development vs Production Binaries - -- **Production**: Uses bundled binaries from `/Applications/HumanLayer.app/Contents/Resources/bin/` -- **Development**: Should use globally installed `humanlayer` (via npm/bun) -- **Never mix**: Don't try to use production binaries in development