The file C:\Users\MikeSwanson\Claude\guru-connect\server\src\session\mod.rs has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
   123→        let (frame_tx, _) = broadcast::channel(16); // Buffer 16 frames
   124→        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64); // Buffer 64 input events
   125→
   126→        let now = chrono::Utc::now();
   127→        let session = Session {
   128→            id: session_id,
   129→            agent_id: agent_id.clone(),
   130→            agent_name,
   131→            started_at: now,
   132→            viewer_count: 0,
   133→            viewers: Vec::new(),
   134→            is_streaming: false,
   135→            is_online: true,
   136→            is_persistent,
   137→            last_heartbeat: now,
   138→            os_version: None,
   139→            is_elevated: false,
   140→            uptime_secs: 0,
   141→            display_count: 1,
   142→            agent_version: None,
   143→        };
   144→
   145→        let session_data = SessionData {
   146→            info: session,
   147→            frame_tx: frame_tx.clone(),

        let session = Session {
            id: session_id,
            agent_id: agent_id.clone(),
            agent_name,
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: true,
            is_persistent,
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
            agent_version: None,
        };

//! Session management for GuruConnect
//!
//! Manages active remote desktop sessions, tracking which agents
//! are connected and which viewers are watching them.

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, RwLock};
use uuid::Uuid;

/// Unique identifier for a session
pub type SessionId = Uuid;

/// Unique identifier for an agent
pub type AgentId = String;

/// Unique identifier for a viewer
pub type ViewerId = String;

/// Information about a connected viewer/technician
#[derive(Debug, Clone)]
pub struct ViewerInfo {
    pub id: ViewerId,
    pub name: String,
    pub connected_at: chrono::DateTime<chrono::Utc>,
}

/// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval)
const HEARTBEAT_TIMEOUT_SECS: u64 = 90;

/// Session state
#[derive(Debug, Clone)]
pub struct Session {
    pub id: SessionId,
    pub agent_id: AgentId,
    pub agent_name: String,
    pub started_at: chrono::DateTime<chrono::Utc>,
    pub viewer_count: usize,
    pub viewers: Vec<ViewerInfo>,  // List of connected technicians
    pub is_streaming: bool,
    pub is_online: bool,  // Whether agent is currently connected
    pub is_persistent: bool,  // Persistent agent (no support code) vs support session
    pub last_heartbeat: chrono::DateTime<chrono::Utc>,
    // Agent status info
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub uptime_secs: i64,
    pub display_count: i32,
    pub agent_version: Option<String>,  // Agent software version
}

/// Channel for sending frames from agent to viewers
pub type FrameSender = broadcast::Sender<Vec<u8>>;
pub type FrameReceiver = broadcast::Receiver<Vec<u8>>;

/// Channel for sending input events from viewer to agent
pub type InputSender = tokio::sync::mpsc::Sender<Vec<u8>>;
pub type InputReceiver = tokio::sync::mpsc::Receiver<Vec<u8>>;

/// Internal session data with channels
struct SessionData {
    info: Session,
    /// Channel for video frames (agent -> viewers)
    frame_tx: FrameSender,
    /// Channel for input events (viewer -> agent)
    input_tx: InputSender,
    input_rx: Option<InputReceiver>,
    /// Map of connected viewers (id -> info)
    viewers: HashMap<ViewerId, ViewerInfo>,
    /// Instant for heartbeat tracking
    last_heartbeat_instant: Instant,
}

/// Manages all active sessions
#[derive(Clone)]
pub struct SessionManager {
    sessions: Arc<RwLock<HashMap<SessionId, SessionData>>>,
    agents: Arc<RwLock<HashMap<AgentId, SessionId>>>,
}

impl SessionManager {
    pub fn new() -> Self {
        Self {
            sessions: Arc::new(RwLock::new(HashMap::new())),
            agents: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Register a new agent and create a session
    /// If agent was previously connected (offline session exists), reuse that session
    pub async fn register_agent(&self, agent_id: AgentId, agent_name: String, is_persistent: bool) -> (SessionId, FrameSender, InputReceiver) {
        // Check if this agent already has an offline session (reconnecting)
        {
            let agents = self.agents.read().await;
            if let Some(&existing_session_id) = agents.get(&agent_id) {
                let mut sessions = self.sessions.write().await;
                if let Some(session_data) = sessions.get_mut(&existing_session_id) {
                    if !session_data.info.is_online {
                        // Reuse existing session - mark as online and create new channels
                        tracing::info!("Agent {} reconnecting to existing session {}", agent_id, existing_session_id);

                        let (frame_tx, _) = broadcast::channel(16);
                        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);

                        session_data.info.is_online = true;
                        session_data.info.last_heartbeat = chrono::Utc::now();
                        session_data.info.agent_name = agent_name; // Update name in case it changed
                        session_data.frame_tx = frame_tx.clone();
                        session_data.input_tx = input_tx;
                        session_data.last_heartbeat_instant = Instant::now();

                        return (existing_session_id, frame_tx, input_rx);
                    }
                }
            }
        }

        // Create new session
        let session_id = Uuid::new_v4();

        // Create channels
        let (frame_tx, _) = broadcast::channel(16); // Buffer 16 frames
        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64); // Buffer 64 input events

        let now = chrono::Utc::now();
        let session = Session {
            id: session_id,
            agent_id: agent_id.clone(),
            agent_name,
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: true,
            is_persistent,
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
        };

        let session_data = SessionData {
            info: session,
            frame_tx: frame_tx.clone(),
            input_tx,
            input_rx: None,
            viewers: HashMap::new(),
            last_heartbeat_instant: Instant::now(),
        };

        let mut sessions = self.sessions.write().await;
        sessions.insert(session_id, session_data);

        let mut agents = self.agents.write().await;
        agents.insert(agent_id, session_id);

        (session_id, frame_tx, input_rx)
    }

    /// Update agent status from heartbeat or status message
    pub async fn update_agent_status(
        &self,
        session_id: SessionId,
        os_version: Option<String>,
        is_elevated: bool,
        uptime_secs: i64,
        display_count: i32,
        is_streaming: bool,
        agent_version: Option<String>,
    ) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            session_data.info.last_heartbeat = chrono::Utc::now();
            session_data.last_heartbeat_instant = Instant::now();
            session_data.info.is_streaming = is_streaming;
            if let Some(os) = os_version {
                session_data.info.os_version = Some(os);
            }
            session_data.info.is_elevated = is_elevated;
            session_data.info.uptime_secs = uptime_secs;
            session_data.info.display_count = display_count;
            if let Some(version) = agent_version {
                session_data.info.agent_version = Some(version);
            }
        }
    }

    /// Update heartbeat timestamp
    pub async fn update_heartbeat(&self, session_id: SessionId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            session_data.info.last_heartbeat = chrono::Utc::now();
            session_data.last_heartbeat_instant = Instant::now();
        }
    }

    /// Check if a session has timed out (no heartbeat for too long)
    pub async fn is_session_timed_out(&self, session_id: SessionId) -> bool {
        let sessions = self.sessions.read().await;
        if let Some(session_data) = sessions.get(&session_id) {
            session_data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS
        } else {
            true // Non-existent sessions are considered timed out
        }
    }

    /// Get sessions that have timed out
    pub async fn get_timed_out_sessions(&self) -> Vec<SessionId> {
        let sessions = self.sessions.read().await;
        sessions
            .iter()
            .filter(|(_, data)| data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS)
            .map(|(id, _)| *id)
            .collect()
    }

    /// Get a session by agent ID
    pub async fn get_session_by_agent(&self, agent_id: &str) -> Option<Session> {
        let agents = self.agents.read().await;
        let session_id = agents.get(agent_id)?;

        let sessions = self.sessions.read().await;
        sessions.get(session_id).map(|s| s.info.clone())
    }

    /// Get a session by session ID
    pub async fn get_session(&self, session_id: SessionId) -> Option<Session> {
        let sessions = self.sessions.read().await;
        sessions.get(&session_id).map(|s| s.info.clone())
    }

    /// Join a session as a viewer, returns channels and sends StartStream to agent
    pub async fn join_session(&self, session_id: SessionId, viewer_id: ViewerId, viewer_name: String) -> Option<(FrameReceiver, InputSender)> {
        let mut sessions = self.sessions.write().await;
        let session_data = sessions.get_mut(&session_id)?;

        let was_empty = session_data.viewers.is_empty();

        // Add viewer info
        let viewer_info = ViewerInfo {
            id: viewer_id.clone(),
            name: viewer_name.clone(),
            connected_at: chrono::Utc::now(),
        };
        session_data.viewers.insert(viewer_id.clone(), viewer_info);

        // Update session info
        session_data.info.viewer_count = session_data.viewers.len();
        session_data.info.viewers = session_data.viewers.values().cloned().collect();

        let frame_rx = session_data.frame_tx.subscribe();
        let input_tx = session_data.input_tx.clone();

        // If this is the first viewer, send StartStream to agent
        if was_empty {
            tracing::info!("Viewer {} ({}) joined session {}, sending StartStream", viewer_name, viewer_id, session_id);
            Self::send_start_stream_internal(session_data, &viewer_id).await;
        } else {
            tracing::info!("Viewer {} ({}) joined session {}", viewer_name, viewer_id, session_id);
        }

        Some((frame_rx, input_tx))
    }

    /// Internal helper to send StartStream message
    async fn send_start_stream_internal(session_data: &SessionData, viewer_id: &str) {
        use crate::proto;
        use prost::Message;

        let start_stream = proto::Message {
            payload: Some(proto::message::Payload::StartStream(proto::StartStream {
                viewer_id: viewer_id.to_string(),
                display_id: 0, // Primary display
            })),
        };

        let mut buf = Vec::new();
        if start_stream.encode(&mut buf).is_ok() {
            let _ = session_data.input_tx.send(buf).await;
        }
    }

    /// Leave a session as a viewer, sends StopStream if no viewers left
    pub async fn leave_session(&self, session_id: SessionId, viewer_id: &ViewerId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            let viewer_name = session_data.viewers.get(viewer_id).map(|v| v.name.clone());
            session_data.viewers.remove(viewer_id);
            session_data.info.viewer_count = session_data.viewers.len();
            session_data.info.viewers = session_data.viewers.values().cloned().collect();

            // If no more viewers, send StopStream to agent
            if session_data.viewers.is_empty() {
                tracing::info!("Last viewer {} ({}) left session {}, sending StopStream",
                    viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id);
                Self::send_stop_stream_internal(session_data, viewer_id).await;
            } else {
                tracing::info!("Viewer {} ({}) left session {}",
                    viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id);
            }
        }
    }

    /// Internal helper to send StopStream message
    async fn send_stop_stream_internal(session_data: &SessionData, viewer_id: &str) {
        use crate::proto;
        use prost::Message;

        let stop_stream = proto::Message {
            payload: Some(proto::message::Payload::StopStream(proto::StopStream {
                viewer_id: viewer_id.to_string(),
            })),
        };

        let mut buf = Vec::new();
        if stop_stream.encode(&mut buf).is_ok() {
            let _ = session_data.input_tx.send(buf).await;
        }
    }

    /// Mark agent as disconnected
    /// For persistent agents: keep session but mark as offline
    /// For support sessions: remove session entirely
    pub async fn mark_agent_disconnected(&self, session_id: SessionId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            if session_data.info.is_persistent {
                // Persistent agent - keep session but mark as offline
                tracing::info!("Persistent agent {} marked offline (session {} preserved)",
                    session_data.info.agent_id, session_id);
                session_data.info.is_online = false;
                session_data.info.is_streaming = false;
                session_data.info.viewer_count = 0;
                session_data.info.viewers.clear();
                session_data.viewers.clear();
            } else {
                // Support session - remove entirely
                let agent_id = session_data.info.agent_id.clone();
                sessions.remove(&session_id);
                drop(sessions); // Release sessions lock before acquiring agents lock
                let mut agents = self.agents.write().await;
                agents.remove(&agent_id);
                tracing::info!("Support session {} removed", session_id);
            }
        }
    }

    /// Remove a session entirely (for cleanup)
    pub async fn remove_session(&self, session_id: SessionId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.remove(&session_id) {
            drop(sessions);
            let mut agents = self.agents.write().await;
            agents.remove(&session_data.info.agent_id);
        }
    }

    /// Disconnect a session by sending a disconnect message to the agent
    /// Returns true if the message was sent successfully
    pub async fn disconnect_session(&self, session_id: SessionId, reason: &str) -> bool {
        let sessions = self.sessions.read().await;
        if let Some(session_data) = sessions.get(&session_id) {
            // Create disconnect message
            use crate::proto;
            use prost::Message;

            let disconnect_msg = proto::Message {
                payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
                    reason: reason.to_string(),
                })),
            };

            let mut buf = Vec::new();
            if disconnect_msg.encode(&mut buf).is_ok() {
                // Send via input channel (will be forwarded to agent's WebSocket)
                if session_data.input_tx.send(buf).await.is_ok() {
                    return true;
                }
            }
        }
        false
    }

    /// List all active sessions
    pub async fn list_sessions(&self) -> Vec<Session> {
        let sessions = self.sessions.read().await;
        sessions.values().map(|s| s.info.clone()).collect()
    }

    /// Send an admin command to an agent (uninstall, restart, etc.)
    /// Returns true if the message was sent successfully
    pub async fn send_admin_command(&self, session_id: SessionId, command: crate::proto::AdminCommandType, reason: &str) -> bool {
        let sessions = self.sessions.read().await;
        if let Some(session_data) = sessions.get(&session_id) {
            if !session_data.info.is_online {
                tracing::warn!("Cannot send admin command to offline agent");
                return false;
            }

            use crate::proto;
            use prost::Message;

            let admin_cmd = proto::Message {
                payload: Some(proto::message::Payload::AdminCommand(proto::AdminCommand {
                    command: command as i32,
                    reason: reason.to_string(),
                })),
            };

            let mut buf = Vec::new();
            if admin_cmd.encode(&mut buf).is_ok() {
                if session_data.input_tx.send(buf).await.is_ok() {
                    tracing::info!("Sent admin command {:?} to session {}", command, session_id);
                    return true;
                }
            }
        }
        false
    }

    /// Remove an agent/machine from the session manager (for deletion)
    /// Returns the agent_id if found
    pub async fn remove_agent(&self, agent_id: &str) -> Option<SessionId> {
        let agents = self.agents.read().await;
        let session_id = agents.get(agent_id).copied()?;
        drop(agents);

        self.remove_session(session_id).await;
        Some(session_id)
    }
}

impl Default for SessionManager {
    fn default() -> Self {
        Self::new()
    }
}

impl SessionManager {
    /// Restore a machine as an offline session (called on startup from database)
    pub async fn restore_offline_machine(&self, agent_id: &str, hostname: &str) -> SessionId {
        let session_id = Uuid::new_v4();
        let now = chrono::Utc::now();

        let session = Session {
            id: session_id,
            agent_id: agent_id.to_string(),
            agent_name: hostname.to_string(),
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: false,  // Offline until agent reconnects
            is_persistent: true,
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
        };

        // Create placeholder channels (will be replaced on reconnect)
        let (frame_tx, _) = broadcast::channel(16);
        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);

        let session_data = SessionData {
            info: session,
            frame_tx,
            input_tx,
            input_rx: Some(input_rx),
            viewers: HashMap::new(),
            last_heartbeat_instant: Instant::now(),
        };

        let mut sessions = self.sessions.write().await;
        sessions.insert(session_id, session_data);

        let mut agents = self.agents.write().await;
        agents.insert(agent_id.to_string(), session_id);

        tracing::info!("Restored offline machine: {} ({})", hostname, agent_id);
        session_id
    }
}


Edit

        let session = Session {
            id: session_id,
            agent_id: agent_id.to_string(),
            agent_name: hostname.to_string(),
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: false,  // Offline until agent reconnects
            is_persistent: true,
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
        };

        let session = Session {
            id: session_id,
            agent_id: agent_id.to_string(),
            agent_name: hostname.to_string(),
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: false,  // Offline until agent reconnects
            is_persistent: true,
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
            agent_version: None,
        };


The file C:\Users\MikeSwanson\Claude\guru-connect\server\src\session\mod.rs has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
   444→    pub async fn restore_offline_machine(&self, agent_id: &str, hostname: &str) -> SessionId {
   445→        let session_id = Uuid::new_v4();
   446→        let now = chrono::Utc::now();
   447→
   448→        let session = Session {
   449→            id: session_id,
   450→            agent_id: agent_id.to_string(),
   451→            agent_name: hostname.to_string(),
   452→            started_at: now,
   453→            viewer_count: 0,
   454→            viewers: Vec::new(),
   455→            is_streaming: false,
   456→            is_online: false,  // Offline until agent reconnects
   457→            is_persistent: true,
   458→            last_heartbeat: now,
   459→            os_version: None,
   460→            is_elevated: false,
   461→            uptime_secs: 0,
   462→            display_count: 1,
   463→            agent_version: None,
   464→        };
   465→
   466→        // Create placeholder channels (will be replaced on reconnect)
   467→        let (frame_tx, _) = broadcast::channel(16);
   468→        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);

        let session = Session {
            id: session_id,
            agent_id: agent_id.to_string(),
            agent_name: hostname.to_string(),
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: false,  // Offline until agent reconnects
            is_persistent: true,
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
            agent_version: None,
        };

//! Session management for GuruConnect
//!
//! Manages active remote desktop sessions, tracking which agents
//! are connected and which viewers are watching them.

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{broadcast, RwLock};
use uuid::Uuid;

/// Unique identifier for a session
pub type SessionId = Uuid;

/// Unique identifier for an agent
pub type AgentId = String;

/// Unique identifier for a viewer
pub type ViewerId = String;

/// Information about a connected viewer/technician
#[derive(Debug, Clone)]
pub struct ViewerInfo {
    pub id: ViewerId,
    pub name: String,
    pub connected_at: chrono::DateTime<chrono::Utc>,
}

/// Heartbeat timeout (90 seconds - 3x the agent's 30 second interval)
const HEARTBEAT_TIMEOUT_SECS: u64 = 90;

/// Session state
#[derive(Debug, Clone)]
pub struct Session {
    pub id: SessionId,
    pub agent_id: AgentId,
    pub agent_name: String,
    pub started_at: chrono::DateTime<chrono::Utc>,
    pub viewer_count: usize,
    pub viewers: Vec<ViewerInfo>,  // List of connected technicians
    pub is_streaming: bool,
    pub is_online: bool,  // Whether agent is currently connected
    pub is_persistent: bool,  // Persistent agent (no support code) vs support session
    pub last_heartbeat: chrono::DateTime<chrono::Utc>,
    // Agent status info
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub uptime_secs: i64,
    pub display_count: i32,
    pub agent_version: Option<String>,  // Agent software version
}

/// Channel for sending frames from agent to viewers
pub type FrameSender = broadcast::Sender<Vec<u8>>;
pub type FrameReceiver = broadcast::Receiver<Vec<u8>>;

/// Channel for sending input events from viewer to agent
pub type InputSender = tokio::sync::mpsc::Sender<Vec<u8>>;
pub type InputReceiver = tokio::sync::mpsc::Receiver<Vec<u8>>;

/// Internal session data with channels
struct SessionData {
    info: Session,
    /// Channel for video frames (agent -> viewers)
    frame_tx: FrameSender,
    /// Channel for input events (viewer -> agent)
    input_tx: InputSender,
    input_rx: Option<InputReceiver>,
    /// Map of connected viewers (id -> info)
    viewers: HashMap<ViewerId, ViewerInfo>,
    /// Instant for heartbeat tracking
    last_heartbeat_instant: Instant,
}

/// Manages all active sessions
#[derive(Clone)]
pub struct SessionManager {
    sessions: Arc<RwLock<HashMap<SessionId, SessionData>>>,
    agents: Arc<RwLock<HashMap<AgentId, SessionId>>>,
}

impl SessionManager {
    pub fn new() -> Self {
        Self {
            sessions: Arc::new(RwLock::new(HashMap::new())),
            agents: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Register a new agent and create a session
    /// If agent was previously connected (offline session exists), reuse that session
    pub async fn register_agent(&self, agent_id: AgentId, agent_name: String, is_persistent: bool) -> (SessionId, FrameSender, InputReceiver) {
        // Check if this agent already has an offline session (reconnecting)
        {
            let agents = self.agents.read().await;
            if let Some(&existing_session_id) = agents.get(&agent_id) {
                let mut sessions = self.sessions.write().await;
                if let Some(session_data) = sessions.get_mut(&existing_session_id) {
                    if !session_data.info.is_online {
                        // Reuse existing session - mark as online and create new channels
                        tracing::info!("Agent {} reconnecting to existing session {}", agent_id, existing_session_id);

                        let (frame_tx, _) = broadcast::channel(16);
                        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);

                        session_data.info.is_online = true;
                        session_data.info.last_heartbeat = chrono::Utc::now();
                        session_data.info.agent_name = agent_name; // Update name in case it changed
                        session_data.frame_tx = frame_tx.clone();
                        session_data.input_tx = input_tx;
                        session_data.last_heartbeat_instant = Instant::now();

                        return (existing_session_id, frame_tx, input_rx);
                    }
                }
            }
        }

        // Create new session
        let session_id = Uuid::new_v4();

        // Create channels
        let (frame_tx, _) = broadcast::channel(16); // Buffer 16 frames
        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64); // Buffer 64 input events

        let now = chrono::Utc::now();
        let session = Session {
            id: session_id,
            agent_id: agent_id.clone(),
            agent_name,
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: true,
            is_persistent,
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
            agent_version: None,
        };

        let session_data = SessionData {
            info: session,
            frame_tx: frame_tx.clone(),
            input_tx,
            input_rx: None,
            viewers: HashMap::new(),
            last_heartbeat_instant: Instant::now(),
        };

        let mut sessions = self.sessions.write().await;
        sessions.insert(session_id, session_data);

        let mut agents = self.agents.write().await;
        agents.insert(agent_id, session_id);

        (session_id, frame_tx, input_rx)
    }

    /// Update agent status from heartbeat or status message
    pub async fn update_agent_status(
        &self,
        session_id: SessionId,
        os_version: Option<String>,
        is_elevated: bool,
        uptime_secs: i64,
        display_count: i32,
        is_streaming: bool,
        agent_version: Option<String>,
    ) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            session_data.info.last_heartbeat = chrono::Utc::now();
            session_data.last_heartbeat_instant = Instant::now();
            session_data.info.is_streaming = is_streaming;
            if let Some(os) = os_version {
                session_data.info.os_version = Some(os);
            }
            session_data.info.is_elevated = is_elevated;
            session_data.info.uptime_secs = uptime_secs;
            session_data.info.display_count = display_count;
            if let Some(version) = agent_version {
                session_data.info.agent_version = Some(version);
            }
        }
    }

    /// Update heartbeat timestamp
    pub async fn update_heartbeat(&self, session_id: SessionId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            session_data.info.last_heartbeat = chrono::Utc::now();
            session_data.last_heartbeat_instant = Instant::now();
        }
    }

    /// Check if a session has timed out (no heartbeat for too long)
    pub async fn is_session_timed_out(&self, session_id: SessionId) -> bool {
        let sessions = self.sessions.read().await;
        if let Some(session_data) = sessions.get(&session_id) {
            session_data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS
        } else {
            true // Non-existent sessions are considered timed out
        }
    }

    /// Get sessions that have timed out
    pub async fn get_timed_out_sessions(&self) -> Vec<SessionId> {
        let sessions = self.sessions.read().await;
        sessions
            .iter()
            .filter(|(_, data)| data.last_heartbeat_instant.elapsed().as_secs() > HEARTBEAT_TIMEOUT_SECS)
            .map(|(id, _)| *id)
            .collect()
    }

    /// Get a session by agent ID
    pub async fn get_session_by_agent(&self, agent_id: &str) -> Option<Session> {
        let agents = self.agents.read().await;
        let session_id = agents.get(agent_id)?;

        let sessions = self.sessions.read().await;
        sessions.get(session_id).map(|s| s.info.clone())
    }

    /// Get a session by session ID
    pub async fn get_session(&self, session_id: SessionId) -> Option<Session> {
        let sessions = self.sessions.read().await;
        sessions.get(&session_id).map(|s| s.info.clone())
    }

    /// Join a session as a viewer, returns channels and sends StartStream to agent
    pub async fn join_session(&self, session_id: SessionId, viewer_id: ViewerId, viewer_name: String) -> Option<(FrameReceiver, InputSender)> {
        let mut sessions = self.sessions.write().await;
        let session_data = sessions.get_mut(&session_id)?;

        let was_empty = session_data.viewers.is_empty();

        // Add viewer info
        let viewer_info = ViewerInfo {
            id: viewer_id.clone(),
            name: viewer_name.clone(),
            connected_at: chrono::Utc::now(),
        };
        session_data.viewers.insert(viewer_id.clone(), viewer_info);

        // Update session info
        session_data.info.viewer_count = session_data.viewers.len();
        session_data.info.viewers = session_data.viewers.values().cloned().collect();

        let frame_rx = session_data.frame_tx.subscribe();
        let input_tx = session_data.input_tx.clone();

        // If this is the first viewer, send StartStream to agent
        if was_empty {
            tracing::info!("Viewer {} ({}) joined session {}, sending StartStream", viewer_name, viewer_id, session_id);
            Self::send_start_stream_internal(session_data, &viewer_id).await;
        } else {
            tracing::info!("Viewer {} ({}) joined session {}", viewer_name, viewer_id, session_id);
        }

        Some((frame_rx, input_tx))
    }

    /// Internal helper to send StartStream message
    async fn send_start_stream_internal(session_data: &SessionData, viewer_id: &str) {
        use crate::proto;
        use prost::Message;

        let start_stream = proto::Message {
            payload: Some(proto::message::Payload::StartStream(proto::StartStream {
                viewer_id: viewer_id.to_string(),
                display_id: 0, // Primary display
            })),
        };

        let mut buf = Vec::new();
        if start_stream.encode(&mut buf).is_ok() {
            let _ = session_data.input_tx.send(buf).await;
        }
    }

    /// Leave a session as a viewer, sends StopStream if no viewers left
    pub async fn leave_session(&self, session_id: SessionId, viewer_id: &ViewerId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            let viewer_name = session_data.viewers.get(viewer_id).map(|v| v.name.clone());
            session_data.viewers.remove(viewer_id);
            session_data.info.viewer_count = session_data.viewers.len();
            session_data.info.viewers = session_data.viewers.values().cloned().collect();

            // If no more viewers, send StopStream to agent
            if session_data.viewers.is_empty() {
                tracing::info!("Last viewer {} ({}) left session {}, sending StopStream",
                    viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id);
                Self::send_stop_stream_internal(session_data, viewer_id).await;
            } else {
                tracing::info!("Viewer {} ({}) left session {}",
                    viewer_name.as_deref().unwrap_or("unknown"), viewer_id, session_id);
            }
        }
    }

    /// Internal helper to send StopStream message
    async fn send_stop_stream_internal(session_data: &SessionData, viewer_id: &str) {
        use crate::proto;
        use prost::Message;

        let stop_stream = proto::Message {
            payload: Some(proto::message::Payload::StopStream(proto::StopStream {
                viewer_id: viewer_id.to_string(),
            })),
        };

        let mut buf = Vec::new();
        if stop_stream.encode(&mut buf).is_ok() {
            let _ = session_data.input_tx.send(buf).await;
        }
    }

    /// Mark agent as disconnected
    /// For persistent agents: keep session but mark as offline
    /// For support sessions: remove session entirely
    pub async fn mark_agent_disconnected(&self, session_id: SessionId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.get_mut(&session_id) {
            if session_data.info.is_persistent {
                // Persistent agent - keep session but mark as offline
                tracing::info!("Persistent agent {} marked offline (session {} preserved)",
                    session_data.info.agent_id, session_id);
                session_data.info.is_online = false;
                session_data.info.is_streaming = false;
                session_data.info.viewer_count = 0;
                session_data.info.viewers.clear();
                session_data.viewers.clear();
            } else {
                // Support session - remove entirely
                let agent_id = session_data.info.agent_id.clone();
                sessions.remove(&session_id);
                drop(sessions); // Release sessions lock before acquiring agents lock
                let mut agents = self.agents.write().await;
                agents.remove(&agent_id);
                tracing::info!("Support session {} removed", session_id);
            }
        }
    }

    /// Remove a session entirely (for cleanup)
    pub async fn remove_session(&self, session_id: SessionId) {
        let mut sessions = self.sessions.write().await;
        if let Some(session_data) = sessions.remove(&session_id) {
            drop(sessions);
            let mut agents = self.agents.write().await;
            agents.remove(&session_data.info.agent_id);
        }
    }

    /// Disconnect a session by sending a disconnect message to the agent
    /// Returns true if the message was sent successfully
    pub async fn disconnect_session(&self, session_id: SessionId, reason: &str) -> bool {
        let sessions = self.sessions.read().await;
        if let Some(session_data) = sessions.get(&session_id) {
            // Create disconnect message
            use crate::proto;
            use prost::Message;

            let disconnect_msg = proto::Message {
                payload: Some(proto::message::Payload::Disconnect(proto::Disconnect {
                    reason: reason.to_string(),
                })),
            };

            let mut buf = Vec::new();
            if disconnect_msg.encode(&mut buf).is_ok() {
                // Send via input channel (will be forwarded to agent's WebSocket)
                if session_data.input_tx.send(buf).await.is_ok() {
                    return true;
                }
            }
        }
        false
    }

    /// List all active sessions
    pub async fn list_sessions(&self) -> Vec<Session> {
        let sessions = self.sessions.read().await;
        sessions.values().map(|s| s.info.clone()).collect()
    }

    /// Send an admin command to an agent (uninstall, restart, etc.)
    /// Returns true if the message was sent successfully
    pub async fn send_admin_command(&self, session_id: SessionId, command: crate::proto::AdminCommandType, reason: &str) -> bool {
        let sessions = self.sessions.read().await;
        if let Some(session_data) = sessions.get(&session_id) {
            if !session_data.info.is_online {
                tracing::warn!("Cannot send admin command to offline agent");
                return false;
            }

            use crate::proto;
            use prost::Message;

            let admin_cmd = proto::Message {
                payload: Some(proto::message::Payload::AdminCommand(proto::AdminCommand {
                    command: command as i32,
                    reason: reason.to_string(),
                })),
            };

            let mut buf = Vec::new();
            if admin_cmd.encode(&mut buf).is_ok() {
                if session_data.input_tx.send(buf).await.is_ok() {
                    tracing::info!("Sent admin command {:?} to session {}", command, session_id);
                    return true;
                }
            }
        }
        false
    }

    /// Remove an agent/machine from the session manager (for deletion)
    /// Returns the agent_id if found
    pub async fn remove_agent(&self, agent_id: &str) -> Option<SessionId> {
        let agents = self.agents.read().await;
        let session_id = agents.get(agent_id).copied()?;
        drop(agents);

        self.remove_session(session_id).await;
        Some(session_id)
    }
}

impl Default for SessionManager {
    fn default() -> Self {
        Self::new()
    }
}

impl SessionManager {
    /// Restore a machine as an offline session (called on startup from database)
    pub async fn restore_offline_machine(&self, agent_id: &str, hostname: &str) -> SessionId {
        let session_id = Uuid::new_v4();
        let now = chrono::Utc::now();

        let session = Session {
            id: session_id,
            agent_id: agent_id.to_string(),
            agent_name: hostname.to_string(),
            started_at: now,
            viewer_count: 0,
            viewers: Vec::new(),
            is_streaming: false,
            is_online: false,  // Offline until agent reconnects
            is_persistent: true,
            last_heartbeat: now,
            os_version: None,
            is_elevated: false,
            uptime_secs: 0,
            display_count: 1,
        };

        // Create placeholder channels (will be replaced on reconnect)
        let (frame_tx, _) = broadcast::channel(16);
        let (input_tx, input_rx) = tokio::sync::mpsc::channel(64);

        let session_data = SessionData {
            info: session,
            frame_tx,
            input_tx,
            input_rx: Some(input_rx),
            viewers: HashMap::new(),
            last_heartbeat_instant: Instant::now(),
        };

        let mut sessions = self.sessions.write().await;
        sessions.insert(session_id, session_data);

        let mut agents = self.agents.write().await;
        agents.insert(agent_id.to_string(), session_id);

        tracing::info!("Restored offline machine: {} ({})", hostname, agent_id);
        session_id
    }
}


Now I need to also update the api/mod.rs SessionInfo struct to include agent_version so the dashboard can see it. Let me check what fields are there.


Now I need to add agent_version to the API SessionInfo struct so the dashboard can see it:


/// Session info returned by API
#[derive(Debug, Serialize)]
pub struct SessionInfo {
    pub id: String,
    pub agent_id: String,
    pub agent_name: String,
    pub started_at: String,
    pub viewer_count: usize,
    pub viewers: Vec<ViewerInfoApi>,
    pub is_streaming: bool,
    pub is_online: bool,
    pub is_persistent: bool,
    pub last_heartbeat: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub uptime_secs: i64,
    pub display_count: i32,
}

/// Session info returned by API
#[derive(Debug, Serialize)]
pub struct SessionInfo {
    pub id: String,
    pub agent_id: String,
    pub agent_name: String,
    pub started_at: String,
    pub viewer_count: usize,
    pub viewers: Vec<ViewerInfoApi>,
    pub is_streaming: bool,
    pub is_online: bool,
    pub is_persistent: bool,
    pub last_heartbeat: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub uptime_secs: i64,
    pub display_count: i32,
    pub agent_version: Option<String>,
}


The file C:\Users\MikeSwanson\Claude\guru-connect\server\src\api\mod.rs has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
    31→        }
    32→    }
    33→}
    34→
    35→/// Session info returned by API
    36→#[derive(Debug, Serialize)]
    37→pub struct SessionInfo {
    38→    pub id: String,
    39→    pub agent_id: String,
    40→    pub agent_name: String,
    41→    pub started_at: String,
    42→    pub viewer_count: usize,
    43→    pub viewers: Vec<ViewerInfoApi>,
    44→    pub is_streaming: bool,
    45→    pub is_online: bool,
    46→    pub is_persistent: bool,
    47→    pub last_heartbeat: String,
    48→    pub os_version: Option<String>,
    49→    pub is_elevated: bool,
    50→    pub uptime_secs: i64,
    51→    pub display_count: i32,
    52→    pub agent_version: Option<String>,
    53→}
    54→
    55→impl From<crate::session::Session> for SessionInfo {
    56→    fn from(s: crate::session::Session) -> Self {
    57→        Self {

/// Session info returned by API
#[derive(Debug, Serialize)]
pub struct SessionInfo {
    pub id: String,
    pub agent_id: String,
    pub agent_name: String,
    pub started_at: String,
    pub viewer_count: usize,
    pub viewers: Vec<ViewerInfoApi>,
    pub is_streaming: bool,
    pub is_online: bool,
    pub is_persistent: bool,
    pub last_heartbeat: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub uptime_secs: i64,
    pub display_count: i32,
    pub agent_version: Option<String>,
}

//! REST API endpoints

pub mod auth;
pub mod users;
pub mod releases;

use axum::{
    extract::{Path, State, Query},
    Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::session::SessionManager;
use crate::db;

/// Viewer info returned by API
#[derive(Debug, Serialize)]
pub struct ViewerInfoApi {
    pub id: String,
    pub name: String,
    pub connected_at: String,
}

impl From<crate::session::ViewerInfo> for ViewerInfoApi {
    fn from(v: crate::session::ViewerInfo) -> Self {
        Self {
            id: v.id,
            name: v.name,
            connected_at: v.connected_at.to_rfc3339(),
        }
    }
}

/// Session info returned by API
#[derive(Debug, Serialize)]
pub struct SessionInfo {
    pub id: String,
    pub agent_id: String,
    pub agent_name: String,
    pub started_at: String,
    pub viewer_count: usize,
    pub viewers: Vec<ViewerInfoApi>,
    pub is_streaming: bool,
    pub is_online: bool,
    pub is_persistent: bool,
    pub last_heartbeat: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub uptime_secs: i64,
    pub display_count: i32,
}

impl From<crate::session::Session> for SessionInfo {
    fn from(s: crate::session::Session) -> Self {
        Self {
            id: s.id.to_string(),
            agent_id: s.agent_id,
            agent_name: s.agent_name,
            started_at: s.started_at.to_rfc3339(),
            viewer_count: s.viewer_count,
            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
            is_streaming: s.is_streaming,
            is_online: s.is_online,
            is_persistent: s.is_persistent,
            last_heartbeat: s.last_heartbeat.to_rfc3339(),
            os_version: s.os_version,
            is_elevated: s.is_elevated,
            uptime_secs: s.uptime_secs,
            display_count: s.display_count,
        }
    }
}

/// List all active sessions
pub async fn list_sessions(
    State(sessions): State<SessionManager>,
) -> Json<Vec<SessionInfo>> {
    let sessions = sessions.list_sessions().await;
    Json(sessions.into_iter().map(SessionInfo::from).collect())
}

/// Get a specific session by ID
pub async fn get_session(
    State(sessions): State<SessionManager>,
    Path(id): Path<String>,
) -> Result<Json<SessionInfo>, (axum::http::StatusCode, &'static str)> {
    let session_id = Uuid::parse_str(&id)
        .map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "Invalid session ID"))?;

    let session = sessions.get_session(session_id).await
        .ok_or((axum::http::StatusCode::NOT_FOUND, "Session not found"))?;

    Ok(Json(SessionInfo::from(session)))
}

// ============================================================================
// Machine API Types
// ============================================================================

/// Machine info returned by API
#[derive(Debug, Serialize)]
pub struct MachineInfo {
    pub id: String,
    pub agent_id: String,
    pub hostname: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub is_persistent: bool,
    pub first_seen: String,
    pub last_seen: String,
    pub status: String,
}

impl From<db::machines::Machine> for MachineInfo {
    fn from(m: db::machines::Machine) -> Self {
        Self {
            id: m.id.to_string(),
            agent_id: m.agent_id,
            hostname: m.hostname,
            os_version: m.os_version,
            is_elevated: m.is_elevated,
            is_persistent: m.is_persistent,
            first_seen: m.first_seen.to_rfc3339(),
            last_seen: m.last_seen.to_rfc3339(),
            status: m.status,
        }
    }
}

/// Session record for history
#[derive(Debug, Serialize)]
pub struct SessionRecord {
    pub id: String,
    pub started_at: String,
    pub ended_at: Option<String>,
    pub duration_secs: Option<i32>,
    pub is_support_session: bool,
    pub support_code: Option<String>,
    pub status: String,
}

impl From<db::sessions::DbSession> for SessionRecord {
    fn from(s: db::sessions::DbSession) -> Self {
        Self {
            id: s.id.to_string(),
            started_at: s.started_at.to_rfc3339(),
            ended_at: s.ended_at.map(|t| t.to_rfc3339()),
            duration_secs: s.duration_secs,
            is_support_session: s.is_support_session,
            support_code: s.support_code,
            status: s.status,
        }
    }
}

/// Event record for history
#[derive(Debug, Serialize)]
pub struct EventRecord {
    pub id: i64,
    pub session_id: String,
    pub event_type: String,
    pub timestamp: String,
    pub viewer_id: Option<String>,
    pub viewer_name: Option<String>,
    pub details: Option<serde_json::Value>,
    pub ip_address: Option<String>,
}

impl From<db::events::SessionEvent> for EventRecord {
    fn from(e: db::events::SessionEvent) -> Self {
        Self {
            id: e.id,
            session_id: e.session_id.to_string(),
            event_type: e.event_type,
            timestamp: e.timestamp.to_rfc3339(),
            viewer_id: e.viewer_id,
            viewer_name: e.viewer_name,
            details: e.details,
            ip_address: e.ip_address,
        }
    }
}

/// Full machine history (for export)
#[derive(Debug, Serialize)]
pub struct MachineHistory {
    pub machine: MachineInfo,
    pub sessions: Vec<SessionRecord>,
    pub events: Vec<EventRecord>,
    pub exported_at: String,
}

/// Query parameters for machine deletion
#[derive(Debug, Deserialize)]
pub struct DeleteMachineParams {
    /// If true, send uninstall command to agent (if online)
    #[serde(default)]
    pub uninstall: bool,
    /// If true, include history in response before deletion
    #[serde(default)]
    pub export: bool,
}

/// Response for machine deletion
#[derive(Debug, Serialize)]
pub struct DeleteMachineResponse {
    pub success: bool,
    pub message: String,
    pub uninstall_sent: bool,
    pub history: Option<MachineHistory>,
}


Now I also need to update the From implementation to include agent_version.


    55→impl From<crate::session::Session> for SessionInfo {
    56→    fn from(s: crate::session::Session) -> Self {
    57→        Self {
    58→            id: s.id.to_string(),
    59→            agent_id: s.agent_id,
    60→            agent_name: s.agent_name,
    61→            started_at: s.started_at.to_rfc3339(),
    62→            viewer_count: s.viewer_count,
    63→            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
    64→            is_streaming: s.is_streaming,
    65→            is_online: s.is_online,
    66→            is_persistent: s.is_persistent,
    67→            last_heartbeat: s.last_heartbeat.to_rfc3339(),
    68→            os_version: s.os_version,
    69→            is_elevated: s.is_elevated,
    70→            uptime_secs: s.uptime_secs,
    71→            display_count: s.display_count,
    72→        }
    73→    }
    74→}
    75→
    76→/// List all active sessions
    77→pub async fn list_sessions(
    78→    State(sessions): State<SessionManager>,
    79→) -> Json<Vec<SessionInfo>> {

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

impl From<crate::session::Session> for SessionInfo {
    fn from(s: crate::session::Session) -> Self {
        Self {
            id: s.id.to_string(),
            agent_id: s.agent_id,
            agent_name: s.agent_name,
            started_at: s.started_at.to_rfc3339(),
            viewer_count: s.viewer_count,
            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
            is_streaming: s.is_streaming,
            is_online: s.is_online,
            is_persistent: s.is_persistent,
            last_heartbeat: s.last_heartbeat.to_rfc3339(),
            os_version: s.os_version,
            is_elevated: s.is_elevated,
            uptime_secs: s.uptime_secs,
            display_count: s.display_count,
        }
    }
}

/// List all active sessions
pub async fn list_sessions(
    State(sessions): State<SessionManager>,
) -> Json<Vec<SessionInfo>> {


I need to add agent_version to the From implementation.


impl From<crate::session::Session> for SessionInfo {
    fn from(s: crate::session::Session) -> Self {
        Self {
            id: s.id.to_string(),
            agent_id: s.agent_id,
            agent_name: s.agent_name,
            started_at: s.started_at.to_rfc3339(),
            viewer_count: s.viewer_count,
            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
            is_streaming: s.is_streaming,
            is_online: s.is_online,
            is_persistent: s.is_persistent,
            last_heartbeat: s.last_heartbeat.to_rfc3339(),
            os_version: s.os_version,
            is_elevated: s.is_elevated,
            uptime_secs: s.uptime_secs,
            display_count: s.display_count,
        }
    }
}

impl From<crate::session::Session> for SessionInfo {
    fn from(s: crate::session::Session) -> Self {
        Self {
            id: s.id.to_string(),
            agent_id: s.agent_id,
            agent_name: s.agent_name,
            started_at: s.started_at.to_rfc3339(),
            viewer_count: s.viewer_count,
            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
            is_streaming: s.is_streaming,
            is_online: s.is_online,
            is_persistent: s.is_persistent,
            last_heartbeat: s.last_heartbeat.to_rfc3339(),
            os_version: s.os_version,
            is_elevated: s.is_elevated,
            uptime_secs: s.uptime_secs,
            display_count: s.display_count,
            agent_version: s.agent_version,
        }
    }
}


The file C:\Users\MikeSwanson\Claude\guru-connect\server\src\api\mod.rs has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
    51→    pub display_count: i32,
    52→    pub agent_version: Option<String>,
    53→}
    54→
    55→impl From<crate::session::Session> for SessionInfo {
    56→    fn from(s: crate::session::Session) -> Self {
    57→        Self {
    58→            id: s.id.to_string(),
    59→            agent_id: s.agent_id,
    60→            agent_name: s.agent_name,
    61→            started_at: s.started_at.to_rfc3339(),
    62→            viewer_count: s.viewer_count,
    63→            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
    64→            is_streaming: s.is_streaming,
    65→            is_online: s.is_online,
    66→            is_persistent: s.is_persistent,
    67→            last_heartbeat: s.last_heartbeat.to_rfc3339(),
    68→            os_version: s.os_version,
    69→            is_elevated: s.is_elevated,
    70→            uptime_secs: s.uptime_secs,
    71→            display_count: s.display_count,
    72→            agent_version: s.agent_version,
    73→        }
    74→    }
    75→}
    76→
    77→/// List all active sessions
    78→pub async fn list_sessions(
    79→    State(sessions): State<SessionManager>,

impl From<crate::session::Session> for SessionInfo {
    fn from(s: crate::session::Session) -> Self {
        Self {
            id: s.id.to_string(),
            agent_id: s.agent_id,
            agent_name: s.agent_name,
            started_at: s.started_at.to_rfc3339(),
            viewer_count: s.viewer_count,
            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
            is_streaming: s.is_streaming,
            is_online: s.is_online,
            is_persistent: s.is_persistent,
            last_heartbeat: s.last_heartbeat.to_rfc3339(),
            os_version: s.os_version,
            is_elevated: s.is_elevated,
            uptime_secs: s.uptime_secs,
            display_count: s.display_count,
            agent_version: s.agent_version,
        }
    }
}

//! REST API endpoints

pub mod auth;
pub mod users;
pub mod releases;

use axum::{
    extract::{Path, State, Query},
    Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::session::SessionManager;
use crate::db;

/// Viewer info returned by API
#[derive(Debug, Serialize)]
pub struct ViewerInfoApi {
    pub id: String,
    pub name: String,
    pub connected_at: String,
}

impl From<crate::session::ViewerInfo> for ViewerInfoApi {
    fn from(v: crate::session::ViewerInfo) -> Self {
        Self {
            id: v.id,
            name: v.name,
            connected_at: v.connected_at.to_rfc3339(),
        }
    }
}

/// Session info returned by API
#[derive(Debug, Serialize)]
pub struct SessionInfo {
    pub id: String,
    pub agent_id: String,
    pub agent_name: String,
    pub started_at: String,
    pub viewer_count: usize,
    pub viewers: Vec<ViewerInfoApi>,
    pub is_streaming: bool,
    pub is_online: bool,
    pub is_persistent: bool,
    pub last_heartbeat: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub uptime_secs: i64,
    pub display_count: i32,
    pub agent_version: Option<String>,
}

impl From<crate::session::Session> for SessionInfo {
    fn from(s: crate::session::Session) -> Self {
        Self {
            id: s.id.to_string(),
            agent_id: s.agent_id,
            agent_name: s.agent_name,
            started_at: s.started_at.to_rfc3339(),
            viewer_count: s.viewer_count,
            viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
            is_streaming: s.is_streaming,
            is_online: s.is_online,
            is_persistent: s.is_persistent,
            last_heartbeat: s.last_heartbeat.to_rfc3339(),
            os_version: s.os_version,
            is_elevated: s.is_elevated,
            uptime_secs: s.uptime_secs,
            display_count: s.display_count,
        }
    }
}

/// List all active sessions
pub async fn list_sessions(
    State(sessions): State<SessionManager>,
) -> Json<Vec<SessionInfo>> {
    let sessions = sessions.list_sessions().await;
    Json(sessions.into_iter().map(SessionInfo::from).collect())
}

/// Get a specific session by ID
pub async fn get_session(
    State(sessions): State<SessionManager>,
    Path(id): Path<String>,
) -> Result<Json<SessionInfo>, (axum::http::StatusCode, &'static str)> {
    let session_id = Uuid::parse_str(&id)
        .map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "Invalid session ID"))?;

    let session = sessions.get_session(session_id).await
        .ok_or((axum::http::StatusCode::NOT_FOUND, "Session not found"))?;

    Ok(Json(SessionInfo::from(session)))
}

// ============================================================================
// Machine API Types
// ============================================================================

/// Machine info returned by API
#[derive(Debug, Serialize)]
pub struct MachineInfo {
    pub id: String,
    pub agent_id: String,
    pub hostname: String,
    pub os_version: Option<String>,
    pub is_elevated: bool,
    pub is_persistent: bool,
    pub first_seen: String,
    pub last_seen: String,
    pub status: String,
}

impl From<db::machines::Machine> for MachineInfo {
    fn from(m: db::machines::Machine) -> Self {
        Self {
            id: m.id.to_string(),
            agent_id: m.agent_id,
            hostname: m.hostname,
            os_version: m.os_version,
            is_elevated: m.is_elevated,
            is_persistent: m.is_persistent,
            first_seen: m.first_seen.to_rfc3339(),
            last_seen: m.last_seen.to_rfc3339(),
            status: m.status,
        }
    }
}

/// Session record for history
#[derive(Debug, Serialize)]
pub struct SessionRecord {
    pub id: String,
    pub started_at: String,
    pub ended_at: Option<String>,
    pub duration_secs: Option<i32>,
    pub is_support_session: bool,
    pub support_code: Option<String>,
    pub status: String,
}

impl From<db::sessions::DbSession> for SessionRecord {
    fn from(s: db::sessions::DbSession) -> Self {
        Self {
            id: s.id.to_string(),
            started_at: s.started_at.to_rfc3339(),
            ended_at: s.ended_at.map(|t| t.to_rfc3339()),
            duration_secs: s.duration_secs,
            is_support_session: s.is_support_session,
            support_code: s.support_code,
            status: s.status,
        }
    }
}

/// Event record for history
#[derive(Debug, Serialize)]
pub struct EventRecord {
    pub id: i64,
    pub session_id: String,
    pub event_type: String,
    pub timestamp: String,
    pub viewer_id: Option<String>,
    pub viewer_name: Option<String>,
    pub details: Option<serde_json::Value>,
    pub ip_address: Option<String>,
}

impl From<db::events::SessionEvent> for EventRecord {
    fn from(e: db::events::SessionEvent) -> Self {
        Self {
            id: e.id,
            session_id: e.session_id.to_string(),
            event_type: e.event_type,
            timestamp: e.timestamp.to_rfc3339(),
            viewer_id: e.viewer_id,
            viewer_name: e.viewer_name,
            details: e.details,
            ip_address: e.ip_address,
        }
    }
}

/// Full machine history (for export)
#[derive(Debug, Serialize)]
pub struct MachineHistory {
    pub machine: MachineInfo,
    pub sessions: Vec<SessionRecord>,
    pub events: Vec<EventRecord>,
    pub exported_at: String,
}

/// Query parameters for machine deletion
#[derive(Debug, Deserialize)]
pub struct DeleteMachineParams {
    /// If true, send uninstall command to agent (if online)
    #[serde(default)]
    pub uninstall: bool,
    /// If true, include history in response before deletion
    #[serde(default)]
    pub export: bool,
}

/// Response for machine deletion
#[derive(Debug, Serialize)]
pub struct DeleteMachineResponse {
    pub success: bool,
    pub message: String,
    pub uninstall_sent: bool,
    pub history: Option<MachineHistory>,
}

+            agent_version: s.agent_version,


Now the server side is mostly complete. Let me update the todo list and move to Phase 3 - the agent side implementation. This is the most complex part where we need to:1. Create the update module
2. Update agent's AgentStatus to include version3. Handle the ADMIN_UPDATE command
4. Implement the actual update logic (download, verify, replace, restart)
5. Add --post-update cleanup


TodoWrite

Phase 1: Update proto/guruconnect.proto with UpdateInfo, UpdateStatus, UpdateState

Phase 1: Create database migration for releases table and machine version columns

Phase 2: Create server db/releases.rs - CRUD for releases table

Phase 2: Create server api/releases.rs - Admin endpoints

Phase 2: Add GET /api/version endpoint (unauthenticated)

Phase 2: Add POST /api/machines/{id}/update endpoint

Phase 2: Track agent versions when they connect

Phase 3: Create agent/src/update.rs module

Phase 3: Add agent_version to AgentStatus messages

Phase 3: Handle ADMIN_UPDATE command in session

Phase 3: Add --post-update flag and cleanup logic

Phase 4: Update dashboard.html with version display and update buttons


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Phase 1: Update proto/guruconnect.proto with UpdateInfo, UpdateStatus, UpdateState

Phase 1: Create database migration for releases table and machine version columns

Phase 2: Create server db/releases.rs - CRUD for releases table

Phase 2: Create server api/releases.rs - Admin endpoints

Phase 2: Add GET /api/version endpoint (unauthenticated)

Phase 2: Add POST /api/machines/{id}/update endpoint

Phase 2: Track agent versions when they connect

Phase 3: Create agent/src/update.rs module

Phase 3: Add periodic update check in session idle loop

Phase 3: Handle ADMIN_UPDATE command in session

Phase 3: Add --post-update flag and cleanup logic

Phase 4: Update dashboard.html with version display and update buttons

Phase 3: Add agent_version to AgentStatus messages


Now let me work on the agent side. First I need to:1. Add dependencies to agent Cargo.toml (reqwest and sha2)2. Create the update.rs module
3. Update the session module to include agent_version in AgentStatus messages4. Handle ADMIN_UPDATE command
5. Add --post-update cleanup logicLet me start by updating the agent's Cargo.toml.


Now let's work on the agent side. First, update the agent's Cargo.toml to add required dependencies:


Edit

# Crypto
ring = "0.17"
sha2 = "0.10"

# HTTP client for updates
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }


The file C:\Users\MikeSwanson\Claude\guru-connect\agent\Cargo.toml has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
    43→
    44→# Configuration
    45→toml = "0.8"
    46→
    47→# Crypto
    48→ring = "0.17"
    49→sha2 = "0.10"
    50→
    51→# HTTP client for updates
    52→reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
    53→
    54→# UUID
    55→uuid = { version = "1", features = ["v4", "serde"] }
    56→

# Crypto
ring = "0.17"
sha2 = "0.10"

# HTTP client for updates
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }

[package]
name = "guruconnect"
version = "0.1.0"
edition = "2021"
authors = ["AZ Computer Guru"]
description = "GuruConnect Remote Desktop - Agent and Viewer"

[dependencies]
# CLI
clap = { version = "4", features = ["derive"] }

# Async runtime
tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread", "macros"] }

# WebSocket
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3"

# Windowing (for viewer)
winit = { version = "0.30", features = ["rwh_06"] }
softbuffer = "0.4"
raw-window-handle = "0.6"

# Compression
zstd = "0.13"

# Protocol (protobuf)
prost = "0.13"
prost-types = "0.13"
bytes = "1"

# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# Error handling
anyhow = "1"
thiserror = "1"

# Configuration
toml = "0.8"

# Crypto
ring = "0.17"

# UUID
uuid = { version = "1", features = ["v4", "serde"] }

# Time
chrono = { version = "0.4", features = ["serde"] }

# Hostname
hostname = "0.4"

# URL encoding
urlencoding = "2"

# System tray (Windows)
tray-icon = "0.19"
muda = "0.15"  # Menu for tray icon

# Image handling for tray icon
image = { version = "0.25", default-features = false, features = ["png"] }

# URL parsing
url = "2"

[target.'cfg(windows)'.dependencies]
# Windows APIs for screen capture, input, and shell operations
windows = { version = "0.58", features = [
    "Win32_Foundation",
    "Win32_Graphics_Gdi",
    "Win32_Graphics_Dxgi",
    "Win32_Graphics_Dxgi_Common",
    "Win32_Graphics_Direct3D",
    "Win32_Graphics_Direct3D11",
    "Win32_UI_Input_KeyboardAndMouse",
    "Win32_UI_WindowsAndMessaging",
    "Win32_UI_Shell",
    "Win32_System_LibraryLoader",
    "Win32_System_Threading",
    "Win32_System_Registry",
    "Win32_System_Console",
    "Win32_System_Environment",
    "Win32_Security",
    "Win32_Storage_FileSystem",
    "Win32_System_Pipes",
    "Win32_System_SystemServices",
    "Win32_System_IO",
]}

# Windows service support
windows-service = "0.7"

[build-dependencies]
prost-build = "0.13"
winres = "0.1"
chrono = "0.4"

[[bin]]
name = "guruconnect"
path = "src/main.rs"

[[bin]]
name = "guruconnect-sas-service"
path = "src/bin/sas_service.rs"

+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }


Now let me create the update.rs module for the agent. This will contain the core update logic.


Now create the agent update module:


Write

//! Auto-update module for GuruConnect agent
//!
//! Handles checking for updates, downloading new versions, and performing
//! in-place binary replacement with restart.

use anyhow::{anyhow, Result};
use sha2::{Sha256, Digest};
use std::path::PathBuf;
use tracing::{info, warn, error};

use crate::build_info;

/// Version information from the server
#[derive(Debug, Clone, serde::Deserialize)]
pub struct VersionInfo {
    pub latest_version: String,
    pub download_url: String,
    pub checksum_sha256: String,
    pub is_mandatory: bool,
    pub release_notes: Option<String>,
}

/// Update state tracking
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum UpdateState {
    Idle,
    Checking,
    Downloading,
    Verifying,
    Installing,
    Restarting,
    Failed,
}

/// Check if an update is available
pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInfo>> {
    let url = format!("{}/api/version", server_base_url.trim_end_matches('/'));
    info!("Checking for updates at {}", url);

    let client = reqwest::Client::builder()
        .danger_accept_invalid_certs(true)  // For self-signed certs in dev
        .build()?;

    let response = client
        .get(&url)
        .timeout(std::time::Duration::from_secs(30))
        .send()
        .await?;

    if response.status() == reqwest::StatusCode::NOT_FOUND {
        info!("No stable release available on server");
        return Ok(None);
    }

    if !response.status().is_success() {
        return Err(anyhow!("Version check failed: HTTP {}", response.status()));
    }

    let version_info: VersionInfo = response.json().await?;

    // Compare versions
    let current = build_info::VERSION;
    if is_newer_version(&version_info.latest_version, current) {
        info!(
            "Update available: {} -> {} (mandatory: {})",
            current, version_info.latest_version, version_info.is_mandatory
        );
        Ok(Some(version_info))
    } else {
        info!("Already running latest version: {}", current);
        Ok(None)
    }
}

/// Simple semantic version comparison
/// Returns true if `available` is newer than `current`
fn is_newer_version(available: &str, current: &str) -> bool {
    // Strip any git hash suffix (e.g., "0.1.0-abc123" -> "0.1.0")
    let available_clean = available.split('-').next().unwrap_or(available);
    let current_clean = current.split('-').next().unwrap_or(current);

    let parse_version = |s: &str| -> Vec<u32> {
        s.split('.')
            .filter_map(|p| p.parse().ok())
            .collect()
    };

    let av = parse_version(available_clean);
    let cv = parse_version(current_clean);

    // Compare component by component
    for i in 0..av.len().max(cv.len()) {
        let a = av.get(i).copied().unwrap_or(0);
        let c = cv.get(i).copied().unwrap_or(0);
        if a > c {
            return true;
        }
        if a < c {
            return false;
        }
    }
    false
}

/// Download update to temporary file
pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
    info!("Downloading update from {}", version_info.download_url);

    let client = reqwest::Client::builder()
        .danger_accept_invalid_certs(true)
        .build()?;

    let response = client
        .get(&version_info.download_url)
        .timeout(std::time::Duration::from_secs(300))  // 5 minutes for large files
        .send()
        .await?;

    if !response.status().is_success() {
        return Err(anyhow!("Download failed: HTTP {}", response.status()));
    }

    // Get temp directory
    let temp_dir = std::env::temp_dir();
    let temp_path = temp_dir.join("guruconnect-update.exe");

    // Download to file
    let bytes = response.bytes().await?;
    std::fs::write(&temp_path, &bytes)?;

    info!("Downloaded {} bytes to {:?}", bytes.len(), temp_path);
    Ok(temp_path)
}

/// Verify downloaded file checksum
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
    info!("Verifying checksum...");

    let contents = std::fs::read(file_path)?;
    let mut hasher = Sha256::new();
    hasher.update(&contents);
    let result = hasher.finalize();
    let computed = format!("{:x}", result);

    let matches = computed.eq_ignore_ascii_case(expected_sha256);

    if matches {
        info!("Checksum verified: {}", computed);
    } else {
        error!("Checksum mismatch! Expected: {}, Got: {}", expected_sha256, computed);
    }

    Ok(matches)
}

/// Perform the actual update installation
/// This renames the current executable and copies the new one in place
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
    info!("Installing update...");

    // Get current executable path
    let current_exe = std::env::current_exe()?;
    let exe_dir = current_exe.parent()
        .ok_or_else(|| anyhow!("Cannot get executable directory"))?;

    // Create paths for backup and new executable
    let backup_path = exe_dir.join("guruconnect.exe.old");

    // Delete any existing backup
    if backup_path.exists() {
        if let Err(e) = std::fs::remove_file(&backup_path) {
            warn!("Could not remove old backup: {}", e);
        }
    }

    // Rename current executable to .old (this works even while running)
    info!("Renaming current exe to backup: {:?}", backup_path);
    std::fs::rename(&current_exe, &backup_path)?;

    // Copy new executable to original location
    info!("Copying new exe to: {:?}", current_exe);
    std::fs::copy(temp_path, &current_exe)?;

    // Clean up temp file
    let _ = std::fs::remove_file(temp_path);

    info!("Update installed successfully");
    Ok(current_exe)
}

/// Spawn new process and exit current one
pub fn restart_with_new_version(exe_path: &PathBuf, args: &[String]) -> Result<()> {
    info!("Restarting with new version...");

    // Build command with --post-update flag
    let mut cmd_args = vec!["--post-update".to_string()];
    cmd_args.extend(args.iter().cloned());

    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
        const DETACHED_PROCESS: u32 = 0x00000008;

        std::process::Command::new(exe_path)
            .args(&cmd_args)
            .creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
            .spawn()?;
    }

    #[cfg(not(windows))]
    {
        std::process::Command::new(exe_path)
            .args(&cmd_args)
            .spawn()?;
    }

    info!("New process spawned, exiting current process");
    Ok(())
}

/// Clean up old executable after successful update
pub fn cleanup_post_update() {
    let current_exe = match std::env::current_exe() {
        Ok(p) => p,
        Err(e) => {
            warn!("Could not get current exe path for cleanup: {}", e);
            return;
        }
    };

    let exe_dir = match current_exe.parent() {
        Some(d) => d,
        None => {
            warn!("Could not get executable directory for cleanup");
            return;
        }
    };

    let backup_path = exe_dir.join("guruconnect.exe.old");

    if backup_path.exists() {
        info!("Cleaning up old executable: {:?}", backup_path);
        match std::fs::remove_file(&backup_path) {
            Ok(_) => info!("Old executable removed successfully"),
            Err(e) => {
                warn!("Could not remove old executable (may be in use): {}", e);
                // On Windows, we might need to schedule deletion on reboot
                #[cfg(windows)]
                schedule_delete_on_reboot(&backup_path);
            }
        }
    }
}

/// Schedule file deletion on reboot (Windows)
#[cfg(windows)]
fn schedule_delete_on_reboot(path: &PathBuf) {
    use std::os::windows::ffi::OsStrExt;
    use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
    use windows::core::PCWSTR;

    let path_wide: Vec<u16> = path.as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();

    unsafe {
        let result = MoveFileExW(
            PCWSTR(path_wide.as_ptr()),
            PCWSTR::null(),
            MOVEFILE_DELAY_UNTIL_REBOOT,
        );
        if result.is_ok() {
            info!("Scheduled {:?} for deletion on reboot", path);
        } else {
            warn!("Failed to schedule {:?} for deletion on reboot", path);
        }
    }
}

/// Perform complete update process
pub async fn perform_update(version_info: &VersionInfo) -> Result<()> {
    // Download
    let temp_path = download_update(version_info).await?;

    // Verify
    if !verify_checksum(&temp_path, &version_info.checksum_sha256)? {
        let _ = std::fs::remove_file(&temp_path);
        return Err(anyhow!("Update verification failed: checksum mismatch"));
    }

    // Install
    let exe_path = install_update(&temp_path)?;

    // Restart
    // Get current args (without the current executable name)
    let args: Vec<String> = std::env::args().skip(1).collect();
    restart_with_new_version(&exe_path, &args)?;

    // Exit current process
    std::process::exit(0);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_version_comparison() {
        assert!(is_newer_version("0.2.0", "0.1.0"));
        assert!(is_newer_version("1.0.0", "0.9.9"));
        assert!(is_newer_version("0.1.1", "0.1.0"));
        assert!(!is_newer_version("0.1.0", "0.1.0"));
        assert!(!is_newer_version("0.1.0", "0.2.0"));
        assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
    }
}


File created successfully at: C:\Users\MikeSwanson\Claude\guru-connect\agent\src\update.rs

//! Auto-update module for GuruConnect agent
//!
//! Handles checking for updates, downloading new versions, and performing
//! in-place binary replacement with restart.

use anyhow::{anyhow, Result};
use sha2::{Sha256, Digest};
use std::path::PathBuf;
use tracing::{info, warn, error};

use crate::build_info;

/// Version information from the server
#[derive(Debug, Clone, serde::Deserialize)]
pub struct VersionInfo {
    pub latest_version: String,
    pub download_url: String,
    pub checksum_sha256: String,
    pub is_mandatory: bool,
    pub release_notes: Option<String>,
}

/// Update state tracking
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum UpdateState {
    Idle,
    Checking,
    Downloading,
    Verifying,
    Installing,
    Restarting,
    Failed,
}

/// Check if an update is available
pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInfo>> {
    let url = format!("{}/api/version", server_base_url.trim_end_matches('/'));
    info!("Checking for updates at {}", url);

    let client = reqwest::Client::builder()
        .danger_accept_invalid_certs(true)  // For self-signed certs in dev
        .build()?;

    let response = client
        .get(&url)
        .timeout(std::time::Duration::from_secs(30))
        .send()
        .await?;

    if response.status() == reqwest::StatusCode::NOT_FOUND {
        info!("No stable release available on server");
        return Ok(None);
    }

    if !response.status().is_success() {
        return Err(anyhow!("Version check failed: HTTP {}", response.status()));
    }

    let version_info: VersionInfo = response.json().await?;

    // Compare versions
    let current = build_info::VERSION;
    if is_newer_version(&version_info.latest_version, current) {
        info!(
            "Update available: {} -> {} (mandatory: {})",
            current, version_info.latest_version, version_info.is_mandatory
        );
        Ok(Some(version_info))
    } else {
        info!("Already running latest version: {}", current);
        Ok(None)
    }
}

/// Simple semantic version comparison
/// Returns true if `available` is newer than `current`
fn is_newer_version(available: &str, current: &str) -> bool {
    // Strip any git hash suffix (e.g., "0.1.0-abc123" -> "0.1.0")
    let available_clean = available.split('-').next().unwrap_or(available);
    let current_clean = current.split('-').next().unwrap_or(current);

    let parse_version = |s: &str| -> Vec<u32> {
        s.split('.')
            .filter_map(|p| p.parse().ok())
            .collect()
    };

    let av = parse_version(available_clean);
    let cv = parse_version(current_clean);

    // Compare component by component
    for i in 0..av.len().max(cv.len()) {
        let a = av.get(i).copied().unwrap_or(0);
        let c = cv.get(i).copied().unwrap_or(0);
        if a > c {
            return true;
        }
        if a < c {
            return false;
        }
    }
    false
}

/// Download update to temporary file
pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
    info!("Downloading update from {}", version_info.download_url);

    let client = reqwest::Client::builder()
        .danger_accept_invalid_certs(true)
        .build()?;

    let response = client
        .get(&version_info.download_url)
        .timeout(std::time::Duration::from_secs(300))  // 5 minutes for large files
        .send()
        .await?;

    if !response.status().is_success() {
        return Err(anyhow!("Download failed: HTTP {}", response.status()));
    }

    // Get temp directory
    let temp_dir = std::env::temp_dir();
    let temp_path = temp_dir.join("guruconnect-update.exe");

    // Download to file
    let bytes = response.bytes().await?;
    std::fs::write(&temp_path, &bytes)?;

    info!("Downloaded {} bytes to {:?}", bytes.len(), temp_path);
    Ok(temp_path)
}

/// Verify downloaded file checksum
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
    info!("Verifying checksum...");

    let contents = std::fs::read(file_path)?;
    let mut hasher = Sha256::new();
    hasher.update(&contents);
    let result = hasher.finalize();
    let computed = format!("{:x}", result);

    let matches = computed.eq_ignore_ascii_case(expected_sha256);

    if matches {
        info!("Checksum verified: {}", computed);
    } else {
        error!("Checksum mismatch! Expected: {}, Got: {}", expected_sha256, computed);
    }

    Ok(matches)
}

/// Perform the actual update installation
/// This renames the current executable and copies the new one in place
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
    info!("Installing update...");

    // Get current executable path
    let current_exe = std::env::current_exe()?;
    let exe_dir = current_exe.parent()
        .ok_or_else(|| anyhow!("Cannot get executable directory"))?;

    // Create paths for backup and new executable
    let backup_path = exe_dir.join("guruconnect.exe.old");

    // Delete any existing backup
    if backup_path.exists() {
        if let Err(e) = std::fs::remove_file(&backup_path) {
            warn!("Could not remove old backup: {}", e);
        }
    }

    // Rename current executable to .old (this works even while running)
    info!("Renaming current exe to backup: {:?}", backup_path);
    std::fs::rename(&current_exe, &backup_path)?;

    // Copy new executable to original location
    info!("Copying new exe to: {:?}", current_exe);
    std::fs::copy(temp_path, &current_exe)?;

    // Clean up temp file
    let _ = std::fs::remove_file(temp_path);

    info!("Update installed successfully");
    Ok(current_exe)
}

/// Spawn new process and exit current one
pub fn restart_with_new_version(exe_path: &PathBuf, args: &[String]) -> Result<()> {
    info!("Restarting with new version...");

    // Build command with --post-update flag
    let mut cmd_args = vec!["--post-update".to_string()];
    cmd_args.extend(args.iter().cloned());

    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
        const DETACHED_PROCESS: u32 = 0x00000008;

        std::process::Command::new(exe_path)
            .args(&cmd_args)
            .creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
            .spawn()?;
    }

    #[cfg(not(windows))]
    {
        std::process::Command::new(exe_path)
            .args(&cmd_args)
            .spawn()?;
    }

    info!("New process spawned, exiting current process");
    Ok(())
}

/// Clean up old executable after successful update
pub fn cleanup_post_update() {
    let current_exe = match std::env::current_exe() {
        Ok(p) => p,
        Err(e) => {
            warn!("Could not get current exe path for cleanup: {}", e);
            return;
        }
    };

    let exe_dir = match current_exe.parent() {
        Some(d) => d,
        None => {
            warn!("Could not get executable directory for cleanup");
            return;
        }
    };

    let backup_path = exe_dir.join("guruconnect.exe.old");

    if backup_path.exists() {
        info!("Cleaning up old executable: {:?}", backup_path);
        match std::fs::remove_file(&backup_path) {
            Ok(_) => info!("Old executable removed successfully"),
            Err(e) => {
                warn!("Could not remove old executable (may be in use): {}", e);
                // On Windows, we might need to schedule deletion on reboot
                #[cfg(windows)]
                schedule_delete_on_reboot(&backup_path);
            }
        }
    }
}

/// Schedule file deletion on reboot (Windows)
#[cfg(windows)]
fn schedule_delete_on_reboot(path: &PathBuf) {
    use std::os::windows::ffi::OsStrExt;
    use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
    use windows::core::PCWSTR;

    let path_wide: Vec<u16> = path.as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();

    unsafe {
        let result = MoveFileExW(
            PCWSTR(path_wide.as_ptr()),
            PCWSTR::null(),
            MOVEFILE_DELAY_UNTIL_REBOOT,
        );
        if result.is_ok() {
            info!("Scheduled {:?} for deletion on reboot", path);
        } else {
            warn!("Failed to schedule {:?} for deletion on reboot", path);
        }
    }
}

/// Perform complete update process
pub async fn perform_update(version_info: &VersionInfo) -> Result<()> {
    // Download
    let temp_path = download_update(version_info).await?;

    // Verify
    if !verify_checksum(&temp_path, &version_info.checksum_sha256)? {
        let _ = std::fs::remove_file(&temp_path);
        return Err(anyhow!("Update verification failed: checksum mismatch"));
    }

    // Install
    let exe_path = install_update(&temp_path)?;

    // Restart
    // Get current args (without the current executable name)
    let args: Vec<String> = std::env::args().skip(1).collect();
    restart_with_new_version(&exe_path, &args)?;

    // Exit current process
    std::process::exit(0);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_version_comparison() {
        assert!(is_newer_version("0.2.0", "0.1.0"));
        assert!(is_newer_version("1.0.0", "0.9.9"));
        assert!(is_newer_version("0.1.1", "0.1.0"));
        assert!(!is_newer_version("0.1.0", "0.1.0"));
        assert!(!is_newer_version("0.1.0", "0.2.0"));
        assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
    }
}


Now I need to:1. Add the update module to main.rs
2. Update the session module to include agent_version in AgentStatus messages3. Handle ADMIN_UPDATE command
4. Add --post-update flag handling

Let me start by updating main.rs to add the module and handle --post-update.


Now add the update module to main.rs and handle --post-update flag:


Edit

mod capture;
mod chat;
mod config;
mod encoder;
mod input;
mod install;
mod sas_client;
mod session;
mod startup;
mod transport;
mod tray;
mod viewer;

mod capture;
mod chat;
mod config;
mod encoder;
mod input;
mod install;
mod sas_client;
mod session;
mod startup;
mod transport;
mod tray;
mod update;
mod viewer;