diff --git a/.gitignore b/.gitignore index 3b00cd9..8d16906 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__/ *.pyc *.pyo /CLAUDE.md +v2/target/ diff --git a/v2/src-tauri/Cargo.lock b/v2/Cargo.lock similarity index 94% rename from v2/src-tauri/Cargo.lock rename to v2/Cargo.lock index 2dc2055..cd84229 100644 --- a/v2/src-tauri/Cargo.lock +++ b/v2/Cargo.lock @@ -68,7 +68,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" dependencies = [ "android_log-sys", - "env_filter", + "env_filter 0.1.4", "log", ] @@ -81,6 +81,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -242,10 +292,11 @@ dependencies = [ name = "bterminal" version = "0.1.0" dependencies = [ + "bterminal-core", "dirs 5.0.1", + "futures-util", "log", "notify", - "portable-pty", "rusqlite", "serde", "serde_json", @@ -254,6 +305,35 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-updater", "tempfile", + "tokio", + "tokio-tungstenite", + "uuid", +] + +[[package]] +name = "bterminal-core" +version = "0.1.0" +dependencies = [ + "log", + "portable-pty", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "bterminal-relay" +version = "0.1.0" +dependencies = [ + "bterminal-core", + "clap", + "env_logger", + "futures-util", + "log", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", "uuid", ] @@ -446,6 +526,52 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "combine" version = "4.6.7" @@ -497,7 +623,7 @@ dependencies = [ "bitflags 2.11.0", "core-foundation", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -627,6 +753,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.8" @@ -831,6 +963,29 @@ dependencies = [ "regex", ] +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter 1.0.0", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -954,6 +1109,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[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" @@ -961,7 +1125,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]] @@ -975,6 +1139,12 @@ dependencies = [ "syn 2.0.117", ] +[[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" @@ -1795,6 +1965,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -1824,6 +2000,30 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "jni" version = "0.21.1" @@ -2153,6 +2353,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2416,12 +2633,56 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.11.0", + "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.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2680,6 +2941,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portable-pty" version = "0.8.1" @@ -3595,6 +3871,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3628,6 +3915,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -4329,11 +4626,35 @@ dependencies = [ "bytes", "libc", "mio 1.1.1", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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-rustls" version = "0.26.4" @@ -4344,6 +4665,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4554,6 +4889,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4674,6 +5029,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.21.0" diff --git a/v2/Cargo.toml b/v2/Cargo.toml new file mode 100644 index 0000000..0988feb --- /dev/null +++ b/v2/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["src-tauri", "bterminal-core", "bterminal-relay"] +resolver = "2" diff --git a/v2/bterminal-core/Cargo.toml b/v2/bterminal-core/Cargo.toml new file mode 100644 index 0000000..263d9ef --- /dev/null +++ b/v2/bterminal-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "bterminal-core" +version = "0.1.0" +edition = "2021" +description = "Shared PTY and sidecar management for BTerminal" +license = "MIT" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +log = "0.4" +portable-pty = "0.8" +uuid = { version = "1", features = ["v4"] } diff --git a/v2/bterminal-core/src/event.rs b/v2/bterminal-core/src/event.rs new file mode 100644 index 0000000..45f3a0b --- /dev/null +++ b/v2/bterminal-core/src/event.rs @@ -0,0 +1,5 @@ +/// Trait for emitting events from PTY and sidecar managers. +/// Implemented by Tauri's AppHandle (controller) and WebSocket sender (relay). +pub trait EventSink: Send + Sync { + fn emit(&self, event: &str, payload: serde_json::Value); +} diff --git a/v2/bterminal-core/src/lib.rs b/v2/bterminal-core/src/lib.rs new file mode 100644 index 0000000..ceebab6 --- /dev/null +++ b/v2/bterminal-core/src/lib.rs @@ -0,0 +1,3 @@ +pub mod event; +pub mod pty; +pub mod sidecar; diff --git a/v2/bterminal-core/src/pty.rs b/v2/bterminal-core/src/pty.rs new file mode 100644 index 0000000..a0ab83e --- /dev/null +++ b/v2/bterminal-core/src/pty.rs @@ -0,0 +1,173 @@ +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{BufReader, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; +use uuid::Uuid; + +use crate::event::EventSink; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PtyOptions { + pub shell: Option, + pub cwd: Option, + pub args: Option>, + pub cols: Option, + pub rows: Option, +} + +struct PtyInstance { + master: Box, + writer: Box, +} + +pub struct PtyManager { + instances: Arc>>, + sink: Arc, +} + +impl PtyManager { + pub fn new(sink: Arc) -> Self { + Self { + instances: Arc::new(Mutex::new(HashMap::new())), + sink, + } + } + + pub fn spawn(&self, options: PtyOptions) -> Result { + let pty_system = native_pty_system(); + let cols = options.cols.unwrap_or(80); + let rows = options.rows.unwrap_or(24); + + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("Failed to open PTY: {e}"))?; + + let shell = options.shell.unwrap_or_else(|| { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) + }); + + let mut cmd = CommandBuilder::new(&shell); + if let Some(args) = &options.args { + for arg in args { + cmd.arg(arg); + } + } + if let Some(cwd) = &options.cwd { + cmd.cwd(cwd); + } + + let _child = pair + .slave + .spawn_command(cmd) + .map_err(|e| format!("Failed to spawn command: {e}"))?; + + drop(pair.slave); + + let id = Uuid::new_v4().to_string(); + let reader = pair + .master + .try_clone_reader() + .map_err(|e| format!("Failed to clone PTY reader: {e}"))?; + let writer = pair + .master + .take_writer() + .map_err(|e| format!("Failed to take PTY writer: {e}"))?; + + let event_id = id.clone(); + let sink = self.sink.clone(); + thread::spawn(move || { + let mut buf_reader = BufReader::with_capacity(4096, reader); + let mut buf = vec![0u8; 4096]; + loop { + match std::io::Read::read(&mut buf_reader, &mut buf) { + Ok(0) => { + sink.emit( + &format!("pty-exit-{event_id}"), + serde_json::Value::Null, + ); + break; + } + Ok(n) => { + let data = String::from_utf8_lossy(&buf[..n]).to_string(); + sink.emit( + &format!("pty-data-{event_id}"), + serde_json::Value::String(data), + ); + } + Err(e) => { + log::error!("PTY read error for {event_id}: {e}"); + sink.emit( + &format!("pty-exit-{event_id}"), + serde_json::Value::Null, + ); + break; + } + } + } + }); + + let instance = PtyInstance { + master: pair.master, + writer, + }; + self.instances.lock().unwrap().insert(id.clone(), instance); + + log::info!("Spawned PTY {id} ({shell})"); + Ok(id) + } + + pub fn write(&self, id: &str, data: &str) -> Result<(), String> { + let mut instances = self.instances.lock().unwrap(); + let instance = instances + .get_mut(id) + .ok_or_else(|| format!("PTY {id} not found"))?; + instance + .writer + .write_all(data.as_bytes()) + .map_err(|e| format!("PTY write error: {e}"))?; + instance + .writer + .flush() + .map_err(|e| format!("PTY flush error: {e}"))?; + Ok(()) + } + + pub fn resize(&self, id: &str, cols: u16, rows: u16) -> Result<(), String> { + let instances = self.instances.lock().unwrap(); + let instance = instances + .get(id) + .ok_or_else(|| format!("PTY {id} not found"))?; + instance + .master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| format!("PTY resize error: {e}"))?; + Ok(()) + } + + pub fn kill(&self, id: &str) -> Result<(), String> { + let mut instances = self.instances.lock().unwrap(); + if instances.remove(id).is_some() { + log::info!("Killed PTY {id}"); + Ok(()) + } else { + Err(format!("PTY {id} not found")) + } + } + + /// List active PTY session IDs. + pub fn list_sessions(&self) -> Vec { + self.instances.lock().unwrap().keys().cloned().collect() + } +} diff --git a/v2/bterminal-core/src/sidecar.rs b/v2/bterminal-core/src/sidecar.rs new file mode 100644 index 0000000..415aad9 --- /dev/null +++ b/v2/bterminal-core/src/sidecar.rs @@ -0,0 +1,261 @@ +// Sidecar lifecycle management (Deno-first, Node.js fallback) +// Spawns agent-runner-deno.ts (or agent-runner.mjs), communicates via stdio NDJSON + +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread; + +use crate::event::EventSink; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentQueryOptions { + pub session_id: String, + pub prompt: String, + pub cwd: Option, + pub max_turns: Option, + pub max_budget_usd: Option, + pub resume_session_id: Option, +} + +/// Directories to search for sidecar scripts. +#[derive(Debug, Clone)] +pub struct SidecarConfig { + pub search_paths: Vec, +} + +struct SidecarCommand { + program: String, + args: Vec, +} + +pub struct SidecarManager { + child: Arc>>, + stdin_writer: Arc>>>, + ready: Arc>, + sink: Arc, + config: SidecarConfig, +} + +impl SidecarManager { + pub fn new(sink: Arc, config: SidecarConfig) -> Self { + Self { + child: Arc::new(Mutex::new(None)), + stdin_writer: Arc::new(Mutex::new(None)), + ready: Arc::new(Mutex::new(false)), + sink, + config, + } + } + + pub fn start(&self) -> Result<(), String> { + let mut child_lock = self.child.lock().unwrap(); + if child_lock.is_some() { + return Err("Sidecar already running".to_string()); + } + + let cmd = self.resolve_sidecar_command()?; + + log::info!("Starting sidecar: {} {}", cmd.program, cmd.args.join(" ")); + + let mut child = Command::new(&cmd.program) + .args(&cmd.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start sidecar: {e}"))?; + + let child_stdin = child + .stdin + .take() + .ok_or("Failed to capture sidecar stdin")?; + let child_stdout = child + .stdout + .take() + .ok_or("Failed to capture sidecar stdout")?; + let child_stderr = child + .stderr + .take() + .ok_or("Failed to capture sidecar stderr")?; + + *self.stdin_writer.lock().unwrap() = Some(Box::new(child_stdin)); + + // Stdout reader thread — forwards NDJSON to event sink + let sink = self.sink.clone(); + let ready = self.ready.clone(); + thread::spawn(move || { + let reader = BufReader::new(child_stdout); + for line in reader.lines() { + match line { + Ok(line) => { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(msg) => { + if msg.get("type").and_then(|t| t.as_str()) == Some("ready") { + *ready.lock().unwrap() = true; + log::info!("Sidecar ready"); + } + sink.emit("sidecar-message", msg); + } + Err(e) => { + log::warn!("Invalid JSON from sidecar: {e}: {line}"); + } + } + } + Err(e) => { + log::error!("Sidecar stdout read error: {e}"); + break; + } + } + } + log::info!("Sidecar stdout reader exited"); + sink.emit("sidecar-exited", serde_json::Value::Null); + }); + + // Stderr reader thread — logs only + thread::spawn(move || { + let reader = BufReader::new(child_stderr); + for line in reader.lines() { + match line { + Ok(line) => log::info!("[sidecar stderr] {line}"), + Err(e) => { + log::error!("Sidecar stderr read error: {e}"); + break; + } + } + } + }); + + *child_lock = Some(child); + Ok(()) + } + + pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> { + let mut writer_lock = self.stdin_writer.lock().unwrap(); + let writer = writer_lock.as_mut().ok_or("Sidecar not running")?; + + let line = + serde_json::to_string(msg).map_err(|e| format!("JSON serialize error: {e}"))?; + + writer + .write_all(line.as_bytes()) + .map_err(|e| format!("Sidecar write error: {e}"))?; + writer + .write_all(b"\n") + .map_err(|e| format!("Sidecar write error: {e}"))?; + writer + .flush() + .map_err(|e| format!("Sidecar flush error: {e}"))?; + + Ok(()) + } + + pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> { + if !*self.ready.lock().unwrap() { + return Err("Sidecar not ready".to_string()); + } + + let msg = serde_json::json!({ + "type": "query", + "sessionId": options.session_id, + "prompt": options.prompt, + "cwd": options.cwd, + "maxTurns": options.max_turns, + "maxBudgetUsd": options.max_budget_usd, + "resumeSessionId": options.resume_session_id, + }); + + self.send_message(&msg) + } + + pub fn stop_session(&self, session_id: &str) -> Result<(), String> { + let msg = serde_json::json!({ + "type": "stop", + "sessionId": session_id, + }); + self.send_message(&msg) + } + + pub fn restart(&self) -> Result<(), String> { + log::info!("Restarting sidecar"); + let _ = self.shutdown(); + self.start() + } + + pub fn shutdown(&self) -> Result<(), String> { + let mut child_lock = self.child.lock().unwrap(); + if let Some(ref mut child) = *child_lock { + log::info!("Shutting down sidecar"); + *self.stdin_writer.lock().unwrap() = None; + let _ = child.kill(); + let _ = child.wait(); + } + *child_lock = None; + *self.ready.lock().unwrap() = false; + Ok(()) + } + + pub fn is_ready(&self) -> bool { + *self.ready.lock().unwrap() + } + + fn resolve_sidecar_command(&self) -> Result { + let mut checked_deno = Vec::new(); + let mut checked_node = Vec::new(); + + // Try Deno first in each search path + for base in &self.config.search_paths { + let deno_path = base.join("agent-runner-deno.ts"); + if deno_path.exists() { + if Command::new("deno").arg("--version").output().is_ok() { + return Ok(SidecarCommand { + program: "deno".to_string(), + args: vec![ + "run".to_string(), + "--allow-run".to_string(), + "--allow-env".to_string(), + "--allow-read".to_string(), + deno_path.to_string_lossy().to_string(), + ], + }); + } + log::warn!( + "Deno sidecar found at {} but deno not in PATH, falling back to Node.js", + deno_path.display() + ); + } + checked_deno.push(deno_path); + } + + // Fallback to Node.js + for base in &self.config.search_paths { + let node_path = base.join("dist").join("agent-runner.mjs"); + if node_path.exists() { + return Ok(SidecarCommand { + program: "node".to_string(), + args: vec![node_path.to_string_lossy().to_string()], + }); + } + checked_node.push(node_path); + } + + let deno_list: Vec<_> = checked_deno.iter().map(|p| p.display().to_string()).collect(); + let node_list: Vec<_> = checked_node.iter().map(|p| p.display().to_string()).collect(); + Err(format!( + "Sidecar not found. Checked Deno ({}) and Node.js ({})", + deno_list.join(", "), + node_list.join(", "), + )) + } +} + +impl Drop for SidecarManager { + fn drop(&mut self) { + let _ = self.shutdown(); + } +} diff --git a/v2/src-tauri/Cargo.toml b/v2/src-tauri/Cargo.toml index 7c264ff..95d38e4 100644 --- a/v2/src-tauri/Cargo.toml +++ b/v2/src-tauri/Cargo.toml @@ -17,17 +17,20 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2.5.6", features = [] } [dependencies] +bterminal-core = { path = "../bterminal-core" } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.10.3", features = [] } tauri-plugin-log = "2" -portable-pty = "0.8" -uuid = { version = "1", features = ["v4"] } rusqlite = { version = "0.31", features = ["bundled"] } dirs = "5" notify = { version = "6", features = ["macos_fsevent"] } tauri-plugin-updater = "2.10.0" +uuid = { version = "1", features = ["v4"] } +tokio-tungstenite = { version = "0.21", features = ["native-tls"] } +tokio = { version = "1", features = ["full"] } +futures-util = "0.3" [dev-dependencies] tempfile = "3" diff --git a/v2/src-tauri/src/event_sink.rs b/v2/src-tauri/src/event_sink.rs new file mode 100644 index 0000000..e8d2378 --- /dev/null +++ b/v2/src-tauri/src/event_sink.rs @@ -0,0 +1,11 @@ +use bterminal_core::event::EventSink; +use tauri::{AppHandle, Emitter}; + +/// Bridges bterminal-core's EventSink trait to Tauri's event system. +pub struct TauriEventSink(pub AppHandle); + +impl EventSink for TauriEventSink { + fn emit(&self, event: &str, payload: serde_json::Value) { + let _ = self.0.emit(event, &payload); + } +} diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 4603006..73377d4 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -1,16 +1,20 @@ mod ctx; +mod event_sink; mod pty; +mod remote; mod sidecar; -mod watcher; mod session; +mod watcher; use ctx::CtxDb; +use event_sink::TauriEventSink; use pty::{PtyManager, PtyOptions}; +use remote::{RemoteManager, RemoteMachineConfig}; use session::{Session, SessionDb, LayoutState, SshSession}; -use sidecar::{AgentQueryOptions, SidecarManager}; +use sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager}; use watcher::FileWatcherManager; use std::sync::Arc; -use tauri::State; +use tauri::{Manager, State}; struct AppState { pty_manager: Arc, @@ -18,17 +22,17 @@ struct AppState { session_db: Arc, file_watcher: Arc, ctx_db: Arc, + remote_manager: Arc, } // --- PTY commands --- #[tauri::command] fn pty_spawn( - app: tauri::AppHandle, state: State<'_, AppState>, options: PtyOptions, ) -> Result { - state.pty_manager.spawn(&app, options) + state.pty_manager.spawn(options) } #[tauri::command] @@ -72,8 +76,8 @@ fn agent_ready(state: State<'_, AppState>) -> bool { } #[tauri::command] -fn agent_restart(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> { - state.sidecar_manager.restart(&app) +fn agent_restart(state: State<'_, AppState>) -> Result<(), String> { + state.sidecar_manager.restart() } // --- File watcher commands --- @@ -201,32 +205,66 @@ fn ctx_search(state: State<'_, AppState>, query: String) -> Result) -> Vec { + state.remote_manager.list_machines() +} + +#[tauri::command] +async fn remote_add(state: State<'_, AppState>, config: RemoteMachineConfig) -> Result { + Ok(state.remote_manager.add_machine(config)) +} + +#[tauri::command] +async fn remote_remove(state: State<'_, AppState>, machine_id: String) -> Result<(), String> { + state.remote_manager.remove_machine(&machine_id) +} + +#[tauri::command] +async fn remote_connect(app: tauri::AppHandle, state: State<'_, AppState>, machine_id: String) -> Result<(), String> { + state.remote_manager.connect(&app, &machine_id).await +} + +#[tauri::command] +async fn remote_disconnect(state: State<'_, AppState>, machine_id: String) -> Result<(), String> { + state.remote_manager.disconnect(&machine_id).await +} + +#[tauri::command] +async fn remote_agent_query(state: State<'_, AppState>, machine_id: String, options: AgentQueryOptions) -> Result<(), String> { + state.remote_manager.agent_query(&machine_id, &options).await +} + +#[tauri::command] +async fn remote_agent_stop(state: State<'_, AppState>, machine_id: String, session_id: String) -> Result<(), String> { + state.remote_manager.agent_stop(&machine_id, &session_id).await +} + +#[tauri::command] +async fn remote_pty_spawn(state: State<'_, AppState>, machine_id: String, options: PtyOptions) -> Result { + state.remote_manager.pty_spawn(&machine_id, &options).await +} + +#[tauri::command] +async fn remote_pty_write(state: State<'_, AppState>, machine_id: String, id: String, data: String) -> Result<(), String> { + state.remote_manager.pty_write(&machine_id, &id, &data).await +} + +#[tauri::command] +async fn remote_pty_resize(state: State<'_, AppState>, machine_id: String, id: String, cols: u16, rows: u16) -> Result<(), String> { + state.remote_manager.pty_resize(&machine_id, &id, cols, rows).await +} + +#[tauri::command] +async fn remote_pty_kill(state: State<'_, AppState>, machine_id: String, id: String) -> Result<(), String> { + state.remote_manager.pty_kill(&machine_id, &id).await +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let pty_manager = Arc::new(PtyManager::new()); - let sidecar_manager = Arc::new(SidecarManager::new()); - - // Initialize session database in app data directory - let data_dir = dirs::data_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("bterminal"); - let session_db = Arc::new( - SessionDb::open(&data_dir).expect("Failed to open session database") - ); - - let file_watcher = Arc::new(FileWatcherManager::new()); - let ctx_db = Arc::new(CtxDb::new()); - - let app_state = AppState { - pty_manager, - sidecar_manager: sidecar_manager.clone(), - session_db, - file_watcher, - ctx_db, - }; - tauri::Builder::default() - .manage(app_state) .invoke_handler(tauri::generate_handler![ pty_spawn, pty_write, @@ -258,6 +296,17 @@ pub fn run() { ctx_get_shared, ctx_get_summaries, ctx_search, + remote_list, + remote_add, + remote_remove, + remote_connect, + remote_disconnect, + remote_agent_query, + remote_agent_stop, + remote_pty_spawn, + remote_pty_write, + remote_pty_resize, + remote_pty_kill, ]) .plugin(tauri_plugin_updater::Builder::new().build()) .setup(move |app| { @@ -269,12 +318,57 @@ pub fn run() { )?; } - // Start sidecar on app launch - match sidecar_manager.start(app.handle()) { + // Create TauriEventSink for core managers + let sink: Arc = + Arc::new(TauriEventSink(app.handle().clone())); + + // Build sidecar config from Tauri paths + let resource_dir = app + .handle() + .path() + .resource_dir() + .unwrap_or_default(); + let dev_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf(); + let sidecar_config = SidecarConfig { + search_paths: vec![ + resource_dir.join("sidecar"), + dev_root.join("sidecar"), + ], + }; + + let pty_manager = Arc::new(PtyManager::new(sink.clone())); + let sidecar_manager = Arc::new(SidecarManager::new(sink, sidecar_config)); + + // Initialize session database + let data_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("bterminal"); + let session_db = Arc::new( + SessionDb::open(&data_dir).expect("Failed to open session database"), + ); + + let file_watcher = Arc::new(FileWatcherManager::new()); + let ctx_db = Arc::new(CtxDb::new()); + let remote_manager = Arc::new(RemoteManager::new()); + + // Start local sidecar + match sidecar_manager.start() { Ok(()) => log::info!("Sidecar startup initiated"), Err(e) => log::warn!("Sidecar startup failed (agent features unavailable): {e}"), } + app.manage(AppState { + pty_manager, + sidecar_manager, + session_db, + file_watcher, + ctx_db, + remote_manager, + }); + Ok(()) }) .run(tauri::generate_context!()) diff --git a/v2/src-tauri/src/pty.rs b/v2/src-tauri/src/pty.rs index 744ebc4..c0d8c25 100644 --- a/v2/src-tauri/src/pty.rs +++ b/v2/src-tauri/src/pty.rs @@ -1,160 +1,4 @@ -use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::io::{BufReader, Write}; -use std::sync::{Arc, Mutex}; -use std::thread; -use tauri::{AppHandle, Emitter}; -use uuid::Uuid; +// Thin wrapper — re-exports bterminal_core::pty types. +// PtyManager is now in bterminal-core; this module only re-exports for lib.rs. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PtyOptions { - pub shell: Option, - pub cwd: Option, - pub args: Option>, - pub cols: Option, - pub rows: Option, -} - -struct PtyInstance { - master: Box, - writer: Box, -} - -pub struct PtyManager { - instances: Arc>>, -} - -impl PtyManager { - pub fn new() -> Self { - Self { - instances: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub fn spawn( - &self, - app: &AppHandle, - options: PtyOptions, - ) -> Result { - let pty_system = native_pty_system(); - let cols = options.cols.unwrap_or(80); - let rows = options.rows.unwrap_or(24); - - let pair = pty_system - .openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| format!("Failed to open PTY: {e}"))?; - - let shell = options.shell.unwrap_or_else(|| { - std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) - }); - - let mut cmd = CommandBuilder::new(&shell); - if let Some(args) = &options.args { - for arg in args { - cmd.arg(arg); - } - } - if let Some(cwd) = &options.cwd { - cmd.cwd(cwd); - } - - let _child = pair - .slave - .spawn_command(cmd) - .map_err(|e| format!("Failed to spawn command: {e}"))?; - - // Drop the slave side — we only need the master - drop(pair.slave); - - let id = Uuid::new_v4().to_string(); - let reader = pair - .master - .try_clone_reader() - .map_err(|e| format!("Failed to clone PTY reader: {e}"))?; - let writer = pair - .master - .take_writer() - .map_err(|e| format!("Failed to take PTY writer: {e}"))?; - - // Spawn reader thread that emits Tauri events - let event_id = id.clone(); - let app_handle = app.clone(); - thread::spawn(move || { - let mut buf_reader = BufReader::with_capacity(4096, reader); - let mut buf = vec![0u8; 4096]; - loop { - match std::io::Read::read(&mut buf_reader, &mut buf) { - Ok(0) => { - // PTY closed - let _ = app_handle.emit(&format!("pty-exit-{event_id}"), ()); - break; - } - Ok(n) => { - let data = String::from_utf8_lossy(&buf[..n]).to_string(); - let _ = app_handle.emit(&format!("pty-data-{event_id}"), &data); - } - Err(e) => { - log::error!("PTY read error for {event_id}: {e}"); - let _ = app_handle.emit(&format!("pty-exit-{event_id}"), ()); - break; - } - } - } - }); - - let instance = PtyInstance { master: pair.master, writer }; - self.instances.lock().unwrap().insert(id.clone(), instance); - - log::info!("Spawned PTY {id} ({shell})"); - Ok(id) - } - - pub fn write(&self, id: &str, data: &str) -> Result<(), String> { - let mut instances = self.instances.lock().unwrap(); - let instance = instances - .get_mut(id) - .ok_or_else(|| format!("PTY {id} not found"))?; - instance - .writer - .write_all(data.as_bytes()) - .map_err(|e| format!("PTY write error: {e}"))?; - instance - .writer - .flush() - .map_err(|e| format!("PTY flush error: {e}"))?; - Ok(()) - } - - pub fn resize(&self, id: &str, cols: u16, rows: u16) -> Result<(), String> { - let instances = self.instances.lock().unwrap(); - let instance = instances - .get(id) - .ok_or_else(|| format!("PTY {id} not found"))?; - instance - .master - .resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| format!("PTY resize error: {e}"))?; - Ok(()) - } - - pub fn kill(&self, id: &str) -> Result<(), String> { - let mut instances = self.instances.lock().unwrap(); - if instances.remove(id).is_some() { - log::info!("Killed PTY {id}"); - Ok(()) - } else { - Err(format!("PTY {id} not found")) - } - } -} +pub use bterminal_core::pty::{PtyManager, PtyOptions}; diff --git a/v2/src-tauri/src/sidecar.rs b/v2/src-tauri/src/sidecar.rs index 19e5bb4..111266f 100644 --- a/v2/src-tauri/src/sidecar.rs +++ b/v2/src-tauri/src/sidecar.rs @@ -1,257 +1,4 @@ -// Sidecar lifecycle management (Deno-first, Node.js fallback) -// Spawns agent-runner-deno.ts (or agent-runner.mjs), communicates via stdio NDJSON +// Thin wrapper — re-exports bterminal_core::sidecar types. +// SidecarManager is now in bterminal-core; this module only re-exports for lib.rs. -use serde::{Deserialize, Serialize}; -use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; -use std::thread; -use tauri::{AppHandle, Emitter, Manager}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentQueryOptions { - pub session_id: String, - pub prompt: String, - pub cwd: Option, - pub max_turns: Option, - pub max_budget_usd: Option, - pub resume_session_id: Option, -} - -struct SidecarCommand { - program: String, - args: Vec, -} - -pub struct SidecarManager { - child: Arc>>, - stdin_writer: Arc>>>, - ready: Arc>, -} - -impl SidecarManager { - pub fn new() -> Self { - Self { - child: Arc::new(Mutex::new(None)), - stdin_writer: Arc::new(Mutex::new(None)), - ready: Arc::new(Mutex::new(false)), - } - } - - pub fn start(&self, app: &AppHandle) -> Result<(), String> { - let mut child_lock = self.child.lock().unwrap(); - if child_lock.is_some() { - return Err("Sidecar already running".to_string()); - } - - // Resolve sidecar command (Deno-first, Node.js fallback) - let cmd = Self::resolve_sidecar_command(app)?; - - log::info!("Starting sidecar: {} {}", cmd.program, cmd.args.join(" ")); - - let mut child = Command::new(&cmd.program) - .args(&cmd.args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to start sidecar: {e}"))?; - - let child_stdin = child.stdin.take().ok_or("Failed to capture sidecar stdin")?; - let child_stdout = child.stdout.take().ok_or("Failed to capture sidecar stdout")?; - let child_stderr = child.stderr.take().ok_or("Failed to capture sidecar stderr")?; - - *self.stdin_writer.lock().unwrap() = Some(Box::new(child_stdin)); - - // Stdout reader thread — forwards NDJSON to Tauri events - let app_handle = app.clone(); - let ready = self.ready.clone(); - thread::spawn(move || { - let reader = BufReader::new(child_stdout); - for line in reader.lines() { - match line { - Ok(line) => { - if line.trim().is_empty() { - continue; - } - match serde_json::from_str::(&line) { - Ok(msg) => { - // Check for ready signal - if msg.get("type").and_then(|t| t.as_str()) == Some("ready") { - *ready.lock().unwrap() = true; - log::info!("Sidecar ready"); - } - let _ = app_handle.emit("sidecar-message", &msg); - } - Err(e) => { - log::warn!("Invalid JSON from sidecar: {e}: {line}"); - } - } - } - Err(e) => { - log::error!("Sidecar stdout read error: {e}"); - break; - } - } - } - log::info!("Sidecar stdout reader exited"); - let _ = app_handle.emit("sidecar-exited", ()); - }); - - // Stderr reader thread — logs only - thread::spawn(move || { - let reader = BufReader::new(child_stderr); - for line in reader.lines() { - match line { - Ok(line) => log::info!("[sidecar stderr] {line}"), - Err(e) => { - log::error!("Sidecar stderr read error: {e}"); - break; - } - } - } - }); - - *child_lock = Some(child); - Ok(()) - } - - pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> { - let mut writer_lock = self.stdin_writer.lock().unwrap(); - let writer = writer_lock - .as_mut() - .ok_or("Sidecar not running")?; - - let line = serde_json::to_string(msg) - .map_err(|e| format!("JSON serialize error: {e}"))?; - - writer - .write_all(line.as_bytes()) - .map_err(|e| format!("Sidecar write error: {e}"))?; - writer - .write_all(b"\n") - .map_err(|e| format!("Sidecar write error: {e}"))?; - writer - .flush() - .map_err(|e| format!("Sidecar flush error: {e}"))?; - - Ok(()) - } - - pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> { - if !*self.ready.lock().unwrap() { - return Err("Sidecar not ready".to_string()); - } - - let msg = serde_json::json!({ - "type": "query", - "sessionId": options.session_id, - "prompt": options.prompt, - "cwd": options.cwd, - "maxTurns": options.max_turns, - "maxBudgetUsd": options.max_budget_usd, - "resumeSessionId": options.resume_session_id, - }); - - self.send_message(&msg) - } - - pub fn stop_session(&self, session_id: &str) -> Result<(), String> { - let msg = serde_json::json!({ - "type": "stop", - "sessionId": session_id, - }); - self.send_message(&msg) - } - - pub fn restart(&self, app: &AppHandle) -> Result<(), String> { - log::info!("Restarting sidecar"); - let _ = self.shutdown(); - self.start(app) - } - - pub fn shutdown(&self) -> Result<(), String> { - let mut child_lock = self.child.lock().unwrap(); - if let Some(ref mut child) = *child_lock { - log::info!("Shutting down sidecar"); - // Drop stdin to signal EOF - *self.stdin_writer.lock().unwrap() = None; - // Give it a moment, then kill - let _ = child.kill(); - let _ = child.wait(); - } - *child_lock = None; - *self.ready.lock().unwrap() = false; - Ok(()) - } - - pub fn is_ready(&self) -> bool { - *self.ready.lock().unwrap() - } - - fn resolve_sidecar_command(app: &AppHandle) -> Result { - let resource_dir = app - .path() - .resource_dir() - .map_err(|e| format!("Failed to get resource dir: {e}"))?; - - let dev_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .to_path_buf(); - - // Try Deno first (runs TypeScript directly, no build step needed) - let deno_paths = [ - resource_dir.join("sidecar").join("agent-runner-deno.ts"), - dev_root.join("sidecar").join("agent-runner-deno.ts"), - ]; - - for path in &deno_paths { - if path.exists() { - // Check if deno is available - if Command::new("deno").arg("--version").output().is_ok() { - return Ok(SidecarCommand { - program: "deno".to_string(), - args: vec![ - "run".to_string(), - "--allow-run".to_string(), - "--allow-env".to_string(), - "--allow-read".to_string(), - path.to_string_lossy().to_string(), - ], - }); - } - log::warn!("Deno sidecar found at {} but deno not in PATH, falling back to Node.js", path.display()); - } - } - - // Fallback to Node.js - let node_paths = [ - resource_dir.join("sidecar").join("dist").join("agent-runner.mjs"), - dev_root.join("sidecar").join("dist").join("agent-runner.mjs"), - ]; - - for path in &node_paths { - if path.exists() { - return Ok(SidecarCommand { - program: "node".to_string(), - args: vec![path.to_string_lossy().to_string()], - }); - } - } - - Err(format!( - "Sidecar not found. Checked Deno ({}, {}) and Node.js ({}, {})", - deno_paths[0].display(), - deno_paths[1].display(), - node_paths[0].display(), - node_paths[1].display(), - )) - } -} - -impl Drop for SidecarManager { - fn drop(&mut self) { - let _ = self.shutdown(); - } -} +pub use bterminal_core::sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};