From f0ec44f6a61ffdea8a327f2af89dc0f1cad9613a Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 6 Mar 2026 01:01:35 +0100 Subject: [PATCH] feat(v2): add SidecarManager and agent Tauri commands Implement Rust SidecarManager that spawns Node.js sidecar process, communicates via stdio NDJSON, and manages agent session lifecycle. Add agent_query, agent_stop, agent_ready Tauri commands. Sidecar auto-starts on app launch. --- v2/src-tauri/src/lib.rs | 42 ++++++- v2/src-tauri/src/sidecar.rs | 218 +++++++++++++++++++++++++++++++++++- 2 files changed, 257 insertions(+), 3 deletions(-) diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 38b47e5..fbd8fef 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -4,13 +4,17 @@ mod watcher; mod session; use pty::{PtyManager, PtyOptions}; +use sidecar::{AgentQueryOptions, SidecarManager}; use std::sync::Arc; use tauri::State; struct AppState { pty_manager: Arc, + sidecar_manager: Arc, } +// --- PTY commands --- + #[tauri::command] fn pty_spawn( app: tauri::AppHandle, @@ -40,10 +44,34 @@ fn pty_kill(state: State<'_, AppState>, id: String) -> Result<(), String> { state.pty_manager.kill(&id) } +// --- Agent/sidecar commands --- + +#[tauri::command] +fn agent_query( + state: State<'_, AppState>, + options: AgentQueryOptions, +) -> Result<(), String> { + state.sidecar_manager.query(&options) +} + +#[tauri::command] +fn agent_stop(state: State<'_, AppState>, session_id: String) -> Result<(), String> { + state.sidecar_manager.stop_session(&session_id) +} + +#[tauri::command] +fn agent_ready(state: State<'_, AppState>) -> bool { + state.sidecar_manager.is_ready() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let pty_manager = Arc::new(PtyManager::new()); + let sidecar_manager = Arc::new(SidecarManager::new()); + let app_state = AppState { - pty_manager: Arc::new(PtyManager::new()), + pty_manager, + sidecar_manager: sidecar_manager.clone(), }; tauri::Builder::default() @@ -53,8 +81,11 @@ pub fn run() { pty_write, pty_resize, pty_kill, + agent_query, + agent_stop, + agent_ready, ]) - .setup(|app| { + .setup(move |app| { if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() @@ -62,6 +93,13 @@ pub fn run() { .build(), )?; } + + // Start sidecar on app launch + match sidecar_manager.start(app.handle()) { + Ok(()) => log::info!("Sidecar startup initiated"), + Err(e) => log::warn!("Sidecar startup failed (agent features unavailable): {e}"), + } + Ok(()) }) .run(tauri::generate_context!()) diff --git a/v2/src-tauri/src/sidecar.rs b/v2/src-tauri/src/sidecar.rs index 526b5ac..1a50bb6 100644 --- a/v2/src-tauri/src/sidecar.rs +++ b/v2/src-tauri/src/sidecar.rs @@ -1,2 +1,218 @@ // Node.js sidecar lifecycle management -// Phase 3: spawn, restart, health check, stdio NDJSON communication +// Spawns agent-runner.ts (compiled), communicates via stdio NDJSON + +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, +} + +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 binary path relative to the app + let sidecar_path = Self::resolve_sidecar_path(app)?; + + log::info!("Starting sidecar: node {}", sidecar_path.display()); + + let mut child = Command::new("node") + .arg(&sidecar_path) + .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 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_path(app: &AppHandle) -> Result { + // In dev mode, use the sidecar source directory + // In production, the built sidecar is bundled with the app + let resource_dir = app + .path() + .resource_dir() + .map_err(|e| format!("Failed to get resource dir: {e}"))?; + + let prod_path = resource_dir.join("sidecar").join("dist").join("agent-runner.mjs"); + if prod_path.exists() { + return Ok(prod_path); + } + + // Dev fallback: look relative to the Cargo project root + let dev_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("sidecar") + .join("dist") + .join("agent-runner.mjs"); + + if dev_path.exists() { + return Ok(dev_path); + } + + Err(format!( + "Sidecar not found at {} or {}", + prod_path.display(), + dev_path.display() + )) + } +} + +impl Drop for SidecarManager { + fn drop(&mut self) { + let _ = self.shutdown(); + } +}