From 1db349a444a36fc363edd4d8d21f9654c668560a Mon Sep 17 00:00:00 2001 From: NessZerra Date: Sun, 12 Jul 2026 07:08:22 +0700 Subject: [PATCH 1/6] Add agent session models Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/agent_sessions.rs | 897 +++++++++++++++++++++++++++++++++++++ rust/src/lib.rs | 1 + 2 files changed, 898 insertions(+) create mode 100644 rust/src/agent_sessions.rs diff --git a/rust/src/agent_sessions.rs b/rust/src/agent_sessions.rs new file mode 100644 index 0000000000..0cd304a3d9 --- /dev/null +++ b/rust/src/agent_sessions.rs @@ -0,0 +1,897 @@ +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentSessionProvider { + Codex, + Claude, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentSessionSource { + Cli, + DesktopApp, + Ide, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentSessionState { + Active, + Idle, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionWorkspace { + pub cwd: Option, + pub project_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionActivity { + pub started_at: Option>, + pub last_activity_at: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum AgentSessionFocusTarget { + Process { pid: u32 }, + Transcript { transcript_path: String }, + None, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSession { + pub id: String, + pub provider: AgentSessionProvider, + pub source: AgentSessionSource, + pub state: AgentSessionState, + pub pid: Option, + pub transcript_path: Option, + pub host: String, + pub workspace: AgentSessionWorkspace, + pub activity: AgentSessionActivity, + pub focus_target: AgentSessionFocusTarget, +} + +impl AgentSession { + #[allow(clippy::too_many_arguments)] + pub fn new( + id: impl Into, + provider: AgentSessionProvider, + source: AgentSessionSource, + state: AgentSessionState, + pid: Option, + transcript_path: Option, + host: impl Into, + workspace: AgentSessionWorkspace, + activity: AgentSessionActivity, + focus_target: AgentSessionFocusTarget, + ) -> Self { + Self { + id: id.into(), + provider, + source, + state, + pid, + transcript_path, + host: host.into(), + workspace, + activity, + focus_target, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionHostResult { + pub host: String, + pub sessions: Vec, + pub error: Option, +} + +impl AgentSessionHostResult { + pub fn success(host: impl Into, sessions: Vec) -> Self { + Self { + host: host.into(), + sessions, + error: None, + } + } + + pub fn failed(host: impl Into, message: impl std::fmt::Display) -> Self { + Self { + host: host.into(), + sessions: Vec::new(), + error: Some(crate::logging::safe_error_message(message)), + } + } + + pub fn from_json(body: &str) -> Result { + RemoteSessionFetcher::decode_host_result(body) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionScanConfig { + pub active_window: Duration, + pub file_only_window: Duration, +} + +impl Default for SessionScanConfig { + fn default() -> Self { + Self { + active_window: Duration::from_secs(120), + file_only_window: Duration::from_secs(30 * 60), + } + } +} + +impl SessionScanConfig { + pub fn state( + &self, + last_activity_at: Option>, + now: DateTime, + has_live_process: bool, + ) -> AgentSessionState { + match last_activity_at { + Some(last_activity_at) => { + let age = now.signed_duration_since(last_activity_at); + let active_window = ChronoDuration::from_std(self.active_window) + .unwrap_or_else(|_| ChronoDuration::seconds(120)); + if age <= active_window { + AgentSessionState::Active + } else { + AgentSessionState::Idle + } + } + None if has_live_process => AgentSessionState::Active, + None => AgentSessionState::Idle, + } + } + + pub fn file_only_session_allowed( + &self, + modified_at: DateTime, + now: DateTime, + ) -> bool { + let age = now.signed_duration_since(modified_at); + let file_window = ChronoDuration::from_std(self.file_only_window) + .unwrap_or_else(|_| ChronoDuration::seconds(30 * 60)); + age <= file_window + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AgentProcessKind { + Agent, + Helper, + AppServer, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentProcessRecord { + pub pid: u32, + pub ppid: u32, + pub started_at: Option>, + pub provider: Option, + pub source: AgentSessionSource, + pub executable: String, + pub kind: AgentProcessKind, +} + +impl AgentProcessRecord { + pub fn is_agent(&self) -> bool { + self.kind == AgentProcessKind::Agent + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeTranscript { + pub url: PathBuf, + pub modified_at: DateTime, +} + +impl ClaudeTranscript { + pub fn new(url: PathBuf, modified_at: DateTime) -> Self { + Self { url, modified_at } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexRolloutMetadata { + pub session_id: String, + pub cwd: Option, + pub originator: Option, + pub source: Option, +} + +impl CodexRolloutMetadata { + pub fn session_source(&self) -> AgentSessionSource { + let value = [self.originator.as_deref(), self.source.as_deref()] + .into_iter() + .flatten() + .map(|part| part.to_ascii_lowercase()) + .collect::>() + .join(" "); + + if value.contains("desktop") || value.contains("app-server") { + AgentSessionSource::DesktopApp + } else if value.contains("ide") + || value.contains("vscode") + || value.contains("cursor") + || value.contains("zed") + { + AgentSessionSource::Ide + } else if value.contains("codex_exec") || value.contains("exec") || value.contains("cli") { + AgentSessionSource::Cli + } else { + AgentSessionSource::Unknown + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "status")] +pub enum SessionFocusResult { + Focused, + Unsupported { message: String }, + Failed { message: String }, +} + +impl SessionFocusResult { + pub fn focused() -> Self { + Self::Focused + } + + pub fn unsupported(message: impl Into) -> Self { + Self::Unsupported { + message: crate::logging::safe_error_message(message.into()), + } + } + + pub fn failed(message: impl Into) -> Self { + Self::Failed { + message: crate::logging::safe_error_message(message.into()), + } + } +} + +pub struct AgentPSOutputParser; +pub struct LSOFCWDOutputParser; +pub struct ClaudeSessionProjectMapper; +pub struct CodexRolloutFirstLineParser; +pub struct AgentSessionCorrelation; +pub struct RemoteSessionFetcher; +pub struct TailscaleStatusParser; + +impl AgentPSOutputParser { + pub fn parse(output: &str) -> Vec { + let mut seen_pids = HashSet::new(); + output + .lines() + .filter_map(|line| Self::parse_line(line, &mut seen_pids)) + .collect() + } + + pub fn agent_processes(records: &[AgentProcessRecord]) -> Vec { + let mut seen = HashSet::new(); + records + .iter() + .filter(|record| record.is_agent()) + .filter_map(|record| { + if seen.insert(record.pid) { + Some(record.clone()) + } else { + None + } + }) + .collect() + } + + pub fn provider(record: &AgentProcessRecord) -> Option { + record.provider + } + + pub fn source(record: &AgentProcessRecord) -> AgentSessionSource { + record.source + } + + pub fn has_codex_app_server(records: &[AgentProcessRecord]) -> bool { + records.iter().any(|record| { + record.kind == AgentProcessKind::AppServer + && record.provider == Some(AgentSessionProvider::Codex) + }) + } + + fn parse_line(line: &str, seen_pids: &mut HashSet) -> Option { + let mut fields = line.split_whitespace(); + let pid = fields.next()?.parse::().ok()?; + let ppid = fields.next()?.parse::().ok()?; + let weekday = fields.next()?; + let month = fields.next()?; + let day = fields.next()?; + let time = fields.next()?; + let year = fields.next()?; + if !seen_pids.insert(pid) { + return None; + } + + let started_at = Self::parse_started_at(weekday, month, day, time, year)?; + let command = fields.collect::>().join(" "); + let classification = classify_process_command(&command); + Some(AgentProcessRecord { + pid, + ppid, + started_at: Some(started_at), + provider: classification.provider, + source: classification.source, + executable: classification.executable, + kind: classification.kind, + }) + } + + fn parse_started_at( + weekday: &str, + month: &str, + day: &str, + time: &str, + year: &str, + ) -> Option> { + let text = format!("{weekday} {month} {day} {time} {year}"); + chrono::NaiveDateTime::parse_from_str(&text, "%a %b %e %H:%M:%S %Y") + .ok() + .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) + } +} + +struct ProcessClassification { + provider: Option, + source: AgentSessionSource, + executable: String, + kind: AgentProcessKind, +} + +fn classify_process_command(command: &str) -> ProcessClassification { + let lower = command.to_ascii_lowercase(); + let executable = executable_basename(command); + + if lower.contains("app-server") && lower.contains("codex") { + return ProcessClassification { + provider: Some(AgentSessionProvider::Codex), + source: AgentSessionSource::DesktopApp, + executable, + kind: AgentProcessKind::AppServer, + }; + } + + if lower.contains("codex (renderer)") + || lower.contains("claude-code-acp") + || lower.contains("--help") + || lower.contains("--version") + || lower.contains("--type=renderer") + || lower.contains("disclaimer") + || executable.eq_ignore_ascii_case("disclaimer") + { + return ProcessClassification { + provider: None, + source: AgentSessionSource::Unknown, + executable, + kind: AgentProcessKind::Helper, + }; + } + + if lower.contains("application support/claude/claude-code/claude") + || lower.contains("claude.app") + || lower.contains("claude.exe") + || executable.eq_ignore_ascii_case("claude") + { + return ProcessClassification { + provider: Some(AgentSessionProvider::Claude), + source: if lower.contains("application support/claude/claude-code") + || lower.contains("claude.app") + { + AgentSessionSource::DesktopApp + } else { + AgentSessionSource::Cli + }, + executable: if executable.eq_ignore_ascii_case("claude") { + "claude".to_string() + } else { + executable + }, + kind: AgentProcessKind::Agent, + }; + } + + if lower.contains("codex.exe") + || lower.contains("codex.app") + || lower.contains("codex desktop") + || executable.eq_ignore_ascii_case("codex") + { + return ProcessClassification { + provider: Some(AgentSessionProvider::Codex), + source: if lower.contains("codex.app") || lower.contains("codex desktop") { + AgentSessionSource::DesktopApp + } else { + AgentSessionSource::Cli + }, + executable: if executable.eq_ignore_ascii_case("codex") { + "codex".to_string() + } else { + executable + }, + kind: AgentProcessKind::Agent, + }; + } + + ProcessClassification { + provider: None, + source: AgentSessionSource::Unknown, + executable, + kind: AgentProcessKind::Other, + } +} + +fn executable_basename(command: &str) -> String { + let normalized = command.replace('\\', "/").to_ascii_lowercase(); + for (needle, name) in [ + ("claude-code-acp", "claude-code-acp"), + ("application support/claude/claude-code/claude", "claude"), + ("codex app-server", "codex"), + ("codex (renderer)", "codex"), + ("codex.app", "codex"), + ("claude.app", "claude"), + ("claude.exe", "claude"), + ("codex.exe", "codex"), + ("disclaimer", "disclaimer"), + ] { + if normalized.contains(needle) { + return name.to_string(); + } + } + + let first_token = command.split_whitespace().next().unwrap_or_default(); + if first_token.is_empty() { + return String::new(); + } + + Path::new(first_token) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(first_token) + .to_string() +} + +impl LSOFCWDOutputParser { + pub fn parse(output: &str) -> HashMap { + let mut result = HashMap::new(); + let mut current_pid = None; + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + match line.chars().next() { + Some('p') => { + current_pid = line[1..].trim().parse::().ok(); + } + Some('n') => { + if let Some(pid) = current_pid { + result.insert(pid, line[1..].to_string()); + } + } + _ => {} + } + } + + result + } +} + +impl ClaudeSessionProjectMapper { + pub fn escaped_cwd(cwd: &str) -> String { + cwd.chars() + .map(|scalar| { + if scalar.is_ascii_alphanumeric() { + scalar + } else { + '-' + } + }) + .collect() + } + + pub fn project_directories(cwd: &str, home_directory: &Path) -> Vec { + if cwd.trim().is_empty() { + return Vec::new(); + } + + vec![ + home_directory + .join(".claude") + .join("projects") + .join(Self::escaped_cwd(cwd)), + ] + } + + pub fn transcripts(cwd: &str, home_directory: &Path) -> Vec { + let mut transcripts = Vec::new(); + + for directory in Self::project_directories(cwd, home_directory) { + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + continue; + } + + let Ok(metadata) = entry.metadata() else { + continue; + }; + let Ok(modified) = metadata.modified() else { + continue; + }; + + transcripts.push(ClaudeTranscript::new(path, modified.into())); + } + } + + transcripts.sort_by(|lhs, rhs| { + rhs.modified_at + .cmp(&lhs.modified_at) + .then_with(|| rhs.url.cmp(&lhs.url)) + }); + transcripts + } + + pub fn newest_transcript(cwd: &str, home_directory: &Path) -> Option { + Self::transcripts(cwd, home_directory).into_iter().next() + } +} + +impl CodexRolloutFirstLineParser { + pub fn parse(line: &str) -> Option { + let value: Value = serde_json::from_str(line).ok()?; + if value.get("type")?.as_str()? != "session_meta" { + return None; + } + + let payload = value.get("payload")?.as_object()?; + let session_id = payload + .get("session_id") + .or_else(|| payload.get("id"))? + .as_str()?; + let session_id = session_id.trim(); + if session_id.is_empty() { + return None; + } + + Some(CodexRolloutMetadata { + session_id: session_id.to_string(), + cwd: payload + .get("cwd") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned), + originator: payload + .get("originator") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned), + source: payload + .get("source") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned), + }) + } + + pub fn read_first_line(path: &Path) -> Option { + let file = File::open(path).ok()?; + let mut reader = BufReader::new(file); + let mut line = String::new(); + let bytes = reader.read_line(&mut line).ok()?; + if bytes == 0 { + return None; + } + while line.ends_with(['\n', '\r']) { + line.pop(); + } + Some(line) + } + + #[allow(clippy::too_many_arguments)] + pub fn make_session( + metadata: CodexRolloutMetadata, + transcript_path: &Path, + modified_at: DateTime, + pid: Option, + started_at: Option>, + host: &str, + config: SessionScanConfig, + now: DateTime, + ) -> Option { + if pid.is_none() && !config.file_only_session_allowed(modified_at, now) { + return None; + } + + let session_source = metadata.session_source(); + let workspace = AgentSessionWorkspace { + cwd: metadata.cwd.clone(), + project_name: metadata.cwd.as_deref().and_then(project_name_from_cwd), + }; + let transcript_path = transcript_path.to_string_lossy().to_string(); + let focus_target = match pid { + Some(pid) => AgentSessionFocusTarget::Process { pid }, + None => AgentSessionFocusTarget::Transcript { + transcript_path: transcript_path.clone(), + }, + }; + + Some(AgentSession::new( + metadata.session_id, + AgentSessionProvider::Codex, + session_source, + config.state(Some(modified_at), now, pid.is_some()), + pid, + Some(transcript_path), + host, + workspace, + AgentSessionActivity { + started_at, + last_activity_at: Some(modified_at), + }, + focus_target, + )) + } +} + +impl AgentSessionCorrelation { + pub fn project_name(cwd: Option<&str>) -> Option { + cwd.and_then(project_name_from_cwd) + } +} + +impl RemoteSessionFetcher { + pub fn sanitized_hosts(hosts: &[String]) -> Vec { + let mut seen = HashSet::new(); + let mut sanitized = Vec::new(); + + for host in hosts { + let Ok(host) = Self::validate_host(host) else { + continue; + }; + + let key = host.to_ascii_lowercase(); + if seen.insert(key) { + sanitized.push(host); + } + } + + sanitized + } + + pub fn validate_host(host: &str) -> Result { + if host.is_empty() { + return Err("host must not be empty".to_string()); + } + if host.starts_with('-') { + return Err("host must not start with '-'".to_string()); + } + if host + .chars() + .any(|c| c.is_control() || c.is_whitespace() || !is_safe_host_char(c)) + { + return Err( + "host must not contain whitespace, control characters, or unsafe shell characters" + .to_string(), + ); + } + + Ok(host.to_string()) + } + + pub fn decode_host_result(body: &str) -> Result { + let result: AgentSessionHostResult = serde_json::from_str(body) + .map_err(|err| actionable_message("Unable to decode remote session response", err))?; + Self::validate_host(&result.host).map_err(|err| { + actionable_message("Remote session response has an invalid host", err) + })?; + Ok(result) + } + + pub fn failed_result(host: &str, err: impl std::fmt::Display) -> AgentSessionHostResult { + AgentSessionHostResult::failed(host.to_string(), err) + } +} + +fn actionable_message(label: &str, err: impl std::fmt::Display) -> String { + crate::logging::safe_error_message(format!("{label}: {err}")) +} + +fn is_safe_host_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']' | '_') +} + +fn project_name_from_cwd(cwd: &str) -> Option { + let trimmed = cwd.trim().trim_end_matches(['\\', '/']); + let path = Path::new(trimmed); + let name = path.file_name()?.to_str()?.trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn process_parser_filters_helpers_app_server_duplicates_and_malformed_lines() { + let output = "\ +101 1 Mon Jul 6 09:00:00 2026 /Applications/Claude.app/Contents/Resources/disclaimer /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions +102 101 Mon Jul 6 09:00:01 2026 /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions +102 101 Mon Jul 6 09:00:01 2026 /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions +201 1 Mon Jul 6 09:01:00 2026 /opt/homebrew/bin/codex exec --full-auto strange argv here +202 1 Mon Jul 6 09:02:00 2026 /Applications/Codex.app/Contents/Resources/codex app-server --listen stdio +203 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/codex --help +301 1 Mon Jul 6 09:04:00 2026 /Users/test/.local/bin/claude-code-acp --stdio +401 1 Mon Jul 6 09:05:00 2026 /Applications/Codex.app/Contents/Frameworks/Codex Framework.framework/Helpers/Codex (Renderer) --type=renderer +bad line +"; + + let records = AgentPSOutputParser::parse(output); + let agents = AgentPSOutputParser::agent_processes(&records); + + assert_eq!(agents.len(), 2); + } + + #[test] + fn session_scan_config_cuts_off_active_and_file_only_windows() { + let config = SessionScanConfig::default(); + let now = Utc.with_ymd_and_hms(2026, 7, 12, 0, 0, 0).unwrap(); + + assert_eq!( + config.state(Some(now - chrono::Duration::seconds(119)), now, true), + AgentSessionState::Active + ); + assert_eq!( + config.state(Some(now - chrono::Duration::seconds(121)), now, true), + AgentSessionState::Idle + ); + assert!(config.file_only_session_allowed(now - chrono::Duration::minutes(29), now)); + assert!(!config.file_only_session_allowed(now - chrono::Duration::minutes(31), now)); + } + + #[test] + fn agent_session_round_trips_json() { + let session = AgentSession { + id: "session-1".to_string(), + provider: AgentSessionProvider::Codex, + source: AgentSessionSource::DesktopApp, + state: AgentSessionState::Active, + pid: Some(1234), + transcript_path: Some("C:\\sessions\\rollout.jsonl".to_string()), + host: "devbox".to_string(), + workspace: AgentSessionWorkspace { + cwd: Some("C:\\work\\proj".to_string()), + project_name: Some("proj".to_string()), + }, + activity: AgentSessionActivity { + started_at: Some(Utc.with_ymd_and_hms(2026, 7, 12, 0, 0, 0).unwrap()), + last_activity_at: Some(Utc.with_ymd_and_hms(2026, 7, 12, 0, 1, 0).unwrap()), + }, + focus_target: AgentSessionFocusTarget::Process { pid: 1234 }, + }; + + let json = serde_json::to_string(&session).unwrap(); + let round_tripped: AgentSession = serde_json::from_str(&json).unwrap(); + assert_eq!(round_tripped, session); + assert!(json.contains("\"focusTarget\"")); + } + + #[test] + fn focus_result_serializes_safely() { + let focused = serde_json::to_value(&SessionFocusResult::Focused).unwrap(); + let unsupported = serde_json::to_value(&SessionFocusResult::Unsupported { + message: "focus unavailable".to_string(), + }) + .unwrap(); + let failed = serde_json::to_value(&SessionFocusResult::Failed { + message: "failed to focus".to_string(), + }) + .unwrap(); + + assert!(focused.is_string() || focused.is_object()); + assert_eq!(unsupported["message"], "focus unavailable"); + assert_eq!(failed["message"], "failed to focus"); + } + + #[test] + fn host_validation_dedupes_and_rejects_unsafe_values() { + let hosts = RemoteSessionFetcher::sanitized_hosts(&[ + "".to_string(), + " ".to_string(), + "-bad".to_string(), + "good".to_string(), + "good".to_string(), + "GOOD".to_string(), + "bad host".to_string(), + "bad\tcontrol".to_string(), + ]); + assert_eq!(hosts, vec!["good".to_string()]); + } + + #[test] + fn codex_rollout_parser_reads_first_line_metadata() { + let metadata = CodexRolloutFirstLineParser::parse( + r#"{"type":"session_meta","payload":{"session_id":"abc","cwd":"C:\\work\\proj","originator":"codex_exec","source":"cli"}}"#, + ) + .unwrap(); + assert_eq!(metadata.session_id, "abc"); + assert_eq!(metadata.cwd.as_deref(), Some("C:\\work\\proj")); + } + + #[test] + fn claude_cwd_escape_is_stable() { + assert_eq!( + ClaudeSessionProjectMapper::escaped_cwd(r"C:\Users\me\My Project!"), + "C--Users-me-My-Project-" + ); + } + + #[test] + fn remote_session_result_round_trips() { + let result = AgentSessionHostResult { + host: "devbox".to_string(), + sessions: vec![AgentSession { + id: "session-1".to_string(), + provider: AgentSessionProvider::Claude, + source: AgentSessionSource::Cli, + state: AgentSessionState::Idle, + pid: None, + transcript_path: None, + host: "devbox".to_string(), + workspace: AgentSessionWorkspace { + cwd: None, + project_name: None, + }, + activity: AgentSessionActivity { + started_at: None, + last_activity_at: None, + }, + focus_target: AgentSessionFocusTarget::None, + }], + error: Some("ssh not found".to_string()), + }; + + let json = serde_json::to_string(&result).unwrap(); + let decoded: AgentSessionHostResult = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, result); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 67afd07fcf..0194e7e0d4 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -3,6 +3,7 @@ //! This keeps the current Rust implementation usable from the existing CLI/bin //! while giving the rewrite a stable crate dependency for future shells. +pub mod agent_sessions; pub mod browser; pub mod cli; pub mod core; From ce6976cd3f17cd08fb854e9c90e23bb7526d88ee Mon Sep 17 00:00:00 2001 From: NessZerra Date: Sun, 12 Jul 2026 09:16:16 +0700 Subject: [PATCH 2/6] Implement agent session discovery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/agent_sessions.rs | 987 +++++++++++++++++++++++++++++++- rust/src/cli/mod.rs | 4 + rust/src/cli/sessions.rs | 196 +++++++ rust/src/host/command_runner.rs | 143 ++++- rust/src/main.rs | 1 + 5 files changed, 1303 insertions(+), 28 deletions(-) create mode 100644 rust/src/cli/sessions.rs diff --git a/rust/src/agent_sessions.rs b/rust/src/agent_sessions.rs index 0cd304a3d9..5d54387bf7 100644 --- a/rust/src/agent_sessions.rs +++ b/rust/src/agent_sessions.rs @@ -1,9 +1,12 @@ -use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use crate::host::{CommandOptions, CommandRunner}; +use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveDate, Utc}; +use futures::future::join_all; use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fs::{self, File}; -use std::io::{BufRead, BufReader}; +use std::future::Future; +use std::io::{BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -277,13 +280,48 @@ impl SessionFocusResult { } pub struct AgentPSOutputParser; +pub struct WindowsProcessOutputParser; pub struct LSOFCWDOutputParser; pub struct ClaudeSessionProjectMapper; +pub struct ClaudeTranscriptMetadataParser; pub struct CodexRolloutFirstLineParser; pub struct AgentSessionCorrelation; -pub struct RemoteSessionFetcher; +#[derive(Debug, Clone)] +pub struct RemoteSessionFetcher { + pub per_host_timeout: Duration, +} pub struct TailscaleStatusParser; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaudeTranscriptMetadata { + pub session_id: Option, + pub cwd: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentSessionDiscoveryMode { + Disabled, + Enabled { ssh_hosts: Vec }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentSessionDiscoveryResult { + Disabled, + Hosts(Vec), +} + +#[derive(Debug, Clone)] +pub struct LocalAgentSessionScanner { + pub config: SessionScanConfig, + pub command_timeout: Duration, +} + +#[derive(Debug, Clone)] +pub struct AgentSessionDiscovery { + local: LocalAgentSessionScanner, + remote: RemoteSessionFetcher, +} + impl AgentPSOutputParser { pub fn parse(output: &str) -> Vec { let mut seen_pids = HashSet::new(); @@ -364,6 +402,80 @@ impl AgentPSOutputParser { } } +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct WindowsProcessMetadata { + process_id: u32, + #[serde(default)] + parent_process_id: u32, + creation_date: Option, + name: Option, + executable_path: Option, +} + +impl WindowsProcessOutputParser { + pub fn parse(output: &str) -> Vec { + let Ok(value) = serde_json::from_str::(output.trim()) else { + return Vec::new(); + }; + let values = match value { + Value::Array(values) => values, + Value::Object(_) => vec![value], + _ => return Vec::new(), + }; + let mut seen = HashSet::new(); + + values + .into_iter() + .filter_map(|value| serde_json::from_value::(value).ok()) + .filter(|process| process.process_id > 0 && seen.insert(process.process_id)) + .map(|process| { + let display_name = process + .name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .or(process.executable_path.as_deref()) + .unwrap_or_default(); + let classification = classify_process_command(display_name); + AgentProcessRecord { + pid: process.process_id, + ppid: process.parent_process_id, + started_at: process + .creation_date + .as_deref() + .and_then(parse_windows_creation_date), + provider: classification.provider, + source: classification.source, + executable: process.name.unwrap_or(classification.executable), + kind: classification.kind, + } + }) + .collect() + } +} + +fn parse_windows_creation_date(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|date| date.with_timezone(&Utc)) + .or_else(|| { + let value = value.strip_prefix("/Date(")?; + let milliseconds = value + .trim_end_matches(")/") + .split(['+', '-']) + .next()? + .parse() + .ok()?; + DateTime::::from_timestamp_millis(milliseconds) + }) + .or_else(|| { + let core = value.get(..21)?; + chrono::NaiveDateTime::parse_from_str(core, "%Y%m%d%H%M%S%.f") + .ok() + .map(|date| DateTime::::from_naive_utc_and_offset(date, Utc)) + }) +} + struct ProcessClassification { provider: Option, source: AgentSessionSource, @@ -574,6 +686,39 @@ impl ClaudeSessionProjectMapper { } } +impl ClaudeTranscriptMetadataParser { + const MAX_LINES: usize = 32; + const MAX_BYTES: u64 = 64 * 1024; + + pub fn parse(reader: impl Read) -> Option { + let mut session_id = None; + let mut cwd = None; + let reader = BufReader::new(reader.take(Self::MAX_BYTES)); + + for line in reader.lines().take(Self::MAX_LINES).map_while(Result::ok) { + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if session_id.is_none() { + session_id = value + .get("sessionId") + .or_else(|| value.get("session_id")) + .and_then(Value::as_str) + .map(str::to_owned); + } + if cwd.is_none() { + cwd = value.get("cwd").and_then(Value::as_str).map(str::to_owned); + } + if session_id.is_some() && cwd.is_some() { + break; + } + } + + (session_id.is_some() || cwd.is_some()) + .then_some(ClaudeTranscriptMetadata { session_id, cwd }) + } +} + impl CodexRolloutFirstLineParser { pub fn parse(line: &str) -> Option { let value: Value = serde_json::from_str(line).ok()?; @@ -674,7 +819,604 @@ impl AgentSessionCorrelation { } } +pub fn focus_session(session: &AgentSession) -> SessionFocusResult { + match session.focus_target { + AgentSessionFocusTarget::Transcript { .. } => { + SessionFocusResult::unsupported("This file-only session has no focusable Windows window.") + } + AgentSessionFocusTarget::None => { + SessionFocusResult::unsupported("This session has no focus target on Windows.") + } + AgentSessionFocusTarget::Process { pid } => { + if !is_local_host(&session.host) { + return SessionFocusResult::unsupported( + "Remote session focus is not supported from this Windows desktop.", + ); + } + + focus_process(pid) + } + } +} + +fn is_local_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") + || host == "127.0.0.1" + || host == "::1" + || host.is_empty() + { + return true; + } + + std::env::var("COMPUTERNAME") + .map(|name| name.eq_ignore_ascii_case(host)) + .unwrap_or(false) +} + +#[cfg(windows)] +fn focus_process(pid: u32) -> SessionFocusResult { + use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; + use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetWindowThreadProcessId, IsWindowVisible, SW_RESTORE, SetForegroundWindow, + ShowWindow, + }; + + struct Search { + pid: u32, + window: Option, + } + + unsafe extern "system" fn find_window(hwnd: HWND, data: LPARAM) -> BOOL { + let search = &mut *(data.0 as *mut Search); + let mut window_pid = 0; + GetWindowThreadProcessId(hwnd, Some(&mut window_pid)); + if window_pid == search.pid && IsWindowVisible(hwnd).as_bool() { + search.window = Some(hwnd); + return BOOL(0); + } + BOOL(1) + } + + let mut search = Search { pid, window: None }; + let result = unsafe { EnumWindows(Some(find_window), LPARAM(&mut search as *mut _ as isize)) }; + if result.is_err() { + return SessionFocusResult::failed("Windows could not enumerate application windows."); + } + + let Some(window) = search.window else { + return SessionFocusResult::failed("No focusable window was found for this session."); + }; + unsafe { + let _ = ShowWindow(window, SW_RESTORE); + if SetForegroundWindow(window).as_bool() { + SessionFocusResult::focused() + } else { + SessionFocusResult::failed("Windows denied the request to focus this session.") + } + } +} + +#[cfg(not(windows))] +fn focus_process(_pid: u32) -> SessionFocusResult { + SessionFocusResult::unsupported("Process focus requires the Windows desktop shell.") +} + +struct CodexRollout { + path: PathBuf, + modified_at: DateTime, + metadata: CodexRolloutMetadata, +} + +struct ClaudeTranscriptCandidate { + path: PathBuf, + modified_at: DateTime, + metadata: ClaudeTranscriptMetadata, +} + +impl Default for LocalAgentSessionScanner { + fn default() -> Self { + Self { + config: SessionScanConfig::default(), + command_timeout: Duration::from_secs(5), + } + } +} + +impl LocalAgentSessionScanner { + pub fn new(config: SessionScanConfig, command_timeout: Duration) -> Self { + Self { + config, + command_timeout, + } + } + + pub async fn scan(&self) -> AgentSessionHostResult { + let host = std::env::var("COMPUTERNAME").unwrap_or_else(|_| "localhost".to_string()); + let options = Self::process_options(self.command_timeout); + let process_result = CommandRunner::new() + .run_async("powershell.exe", None, &options) + .await; + let (processes, error) = match process_result { + Ok(result) if result.timed_out => ( + Vec::new(), + Some("Windows process discovery timed out; file-only sessions may still appear."), + ), + Ok(result) if result.exit_code == Some(0) => { + (WindowsProcessOutputParser::parse(&result.text), None) + } + Ok(_) => ( + Vec::new(), + Some( + "Windows process discovery failed; verify PowerShell and CIM access. File-only sessions may still appear.", + ), + ), + Err(_) => ( + Vec::new(), + Some( + "Unable to launch PowerShell for process discovery; file-only sessions may still appear.", + ), + ), + }; + + let now = Utc::now(); + let sessions = self.scan_files( + &host, + now, + &Self::codex_sessions_root(), + &Self::claude_projects_roots(), + &processes, + ); + AgentSessionHostResult { + host, + sessions, + error: error.map(crate::logging::safe_error_message), + } + } + + fn process_options(timeout: Duration) -> CommandOptions { + CommandOptions { + timeout, + initial_delay: Duration::ZERO, + extra_args: vec![ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + concat!( + "$ErrorActionPreference='Stop';", + "$processes=@(Get-CimInstance Win32_Process | ", + "Select-Object ProcessId,ParentProcessId,CreationDate,Name,ExecutablePath);", + "ConvertTo-Json -Compress -InputObject $processes" + ) + .to_string(), + ], + ..CommandOptions::default() + } + } + + fn scan_files( + &self, + host: &str, + now: DateTime, + codex_root: &Path, + claude_roots: &[PathBuf], + processes: &[AgentProcessRecord], + ) -> Vec { + let mut agents = AgentPSOutputParser::agent_processes(processes); + agents.sort_by_key(|process| std::cmp::Reverse(process.started_at)); + let mut rollouts = VecDeque::from(Self::codex_rollouts( + codex_root, + now.with_timezone(&Local).date_naive(), + )); + let claude_count = agents + .iter() + .filter(|process| process.provider == Some(AgentSessionProvider::Claude)) + .count(); + let mut claude_transcripts = + VecDeque::from(Self::claude_transcripts(claude_roots, claude_count)); + let mut sessions = Vec::new(); + + for process in agents { + match process.provider { + Some(AgentSessionProvider::Codex) => sessions.push(self.codex_process_session( + host, + now, + process, + rollouts.pop_front(), + )), + Some(AgentSessionProvider::Claude) => sessions.push(self.claude_process_session( + host, + now, + process, + claude_transcripts.pop_front(), + )), + None => {} + } + } + + sessions.extend(rollouts.into_iter().filter_map(|rollout| { + CodexRolloutFirstLineParser::make_session( + rollout.metadata, + &rollout.path, + rollout.modified_at, + None, + None, + host, + self.config, + now, + ) + })); + sessions.sort_by(|lhs, rhs| { + (rhs.state == AgentSessionState::Active) + .cmp(&(lhs.state == AgentSessionState::Active)) + .then_with(|| { + rhs.activity + .last_activity_at + .or(rhs.activity.started_at) + .cmp(&lhs.activity.last_activity_at.or(lhs.activity.started_at)) + }) + }); + let mut seen = HashSet::new(); + sessions.retain(|session| seen.insert(format!("{}:{}", session.host, session.id))); + sessions + } + + fn codex_process_session( + &self, + host: &str, + now: DateTime, + process: AgentProcessRecord, + rollout: Option, + ) -> AgentSession { + let cwd = rollout + .as_ref() + .and_then(|rollout| rollout.metadata.cwd.clone()); + let source = rollout + .as_ref() + .map(|rollout| rollout.metadata.session_source()) + .filter(|source| *source != AgentSessionSource::Unknown) + .unwrap_or(process.source); + let modified_at = rollout.as_ref().map(|rollout| rollout.modified_at); + let transcript_path = rollout + .as_ref() + .map(|rollout| rollout.path.to_string_lossy().to_string()); + AgentSession::new( + rollout + .as_ref() + .map(|rollout| rollout.metadata.session_id.clone()) + .unwrap_or_else(|| format!("pid:{}", process.pid)), + AgentSessionProvider::Codex, + source, + self.config.state(modified_at, now, true), + Some(process.pid), + transcript_path, + host, + AgentSessionWorkspace { + project_name: cwd.as_deref().and_then(project_name_from_cwd), + cwd, + }, + AgentSessionActivity { + started_at: process.started_at, + last_activity_at: modified_at, + }, + AgentSessionFocusTarget::Process { pid: process.pid }, + ) + } + + fn claude_process_session( + &self, + host: &str, + now: DateTime, + process: AgentProcessRecord, + transcript: Option, + ) -> AgentSession { + let cwd = transcript + .as_ref() + .and_then(|transcript| transcript.metadata.cwd.clone()); + let modified_at = transcript.as_ref().map(|transcript| transcript.modified_at); + let transcript_path = transcript + .as_ref() + .map(|transcript| transcript.path.to_string_lossy().to_string()); + let id = transcript + .as_ref() + .and_then(|transcript| transcript.metadata.session_id.clone()) + .or_else(|| { + transcript.as_ref().and_then(|transcript| { + transcript + .path + .file_stem() + .and_then(|name| name.to_str()) + .map(str::to_owned) + }) + }) + .unwrap_or_else(|| format!("pid:{}", process.pid)); + AgentSession::new( + id, + AgentSessionProvider::Claude, + process.source, + self.config.state(modified_at, now, true), + Some(process.pid), + transcript_path, + host, + AgentSessionWorkspace { + project_name: cwd.as_deref().and_then(project_name_from_cwd), + cwd, + }, + AgentSessionActivity { + started_at: process.started_at, + last_activity_at: modified_at, + }, + AgentSessionFocusTarget::Process { pid: process.pid }, + ) + } + + fn codex_sessions_root() -> PathBuf { + if let Ok(path) = std::env::var("CODEX_HOME") + && !path.trim().is_empty() + { + let path = PathBuf::from(path.trim()); + if path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("sessions")) + { + return path; + } + return path.join("sessions"); + } + dirs::home_dir() + .unwrap_or_default() + .join(".codex") + .join("sessions") + } + + fn claude_projects_roots() -> Vec { + if let Ok(path) = std::env::var("CLAUDE_CONFIG_DIR") + && !path.trim().is_empty() + { + return vec![PathBuf::from(path.trim()).join("projects")]; + } + dirs::home_dir() + .map(|home| vec![home.join(".claude").join("projects")]) + .unwrap_or_default() + } + + fn codex_day_directories(root: &Path, today: NaiveDate) -> Vec { + [today, today - ChronoDuration::days(1)] + .into_iter() + .map(|date| { + root.join(date.format("%Y").to_string()) + .join(date.format("%m").to_string()) + .join(date.format("%d").to_string()) + }) + .collect() + } + + fn codex_rollouts(root: &Path, today: NaiveDate) -> Vec { + let mut rollouts = Vec::new(); + for directory in Self::codex_day_directories(root, today) { + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let is_rollout = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("rollout-")); + if !is_rollout || path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + continue; + } + let Some(line) = CodexRolloutFirstLineParser::read_first_line(&path) else { + continue; + }; + let Some(metadata) = CodexRolloutFirstLineParser::parse(&line) else { + continue; + }; + let Some(modified_at) = entry + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(DateTime::::from) + else { + continue; + }; + rollouts.push(CodexRollout { + path, + modified_at, + metadata, + }); + } + } + rollouts.sort_by_key(|rollout| std::cmp::Reverse(rollout.modified_at)); + rollouts + } + + fn claude_transcripts( + roots: &[PathBuf], + live_process_count: usize, + ) -> Vec { + if live_process_count == 0 { + return Vec::new(); + } + let mut files = Vec::new(); + for root in roots { + let Ok(projects) = fs::read_dir(root) else { + continue; + }; + for project in projects.flatten() { + let Ok(entries) = fs::read_dir(project.path()) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + continue; + } + let Some(modified_at) = entry + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .map(DateTime::::from) + else { + continue; + }; + files.push((path, modified_at)); + } + } + } + files.sort_by(|lhs, rhs| rhs.1.cmp(&lhs.1).then_with(|| rhs.0.cmp(&lhs.0))); + files + .into_iter() + .take(live_process_count) + .filter_map(|(path, modified_at)| { + let file = File::open(&path).ok()?; + let metadata = ClaudeTranscriptMetadataParser::parse(file)?; + Some(ClaudeTranscriptCandidate { + path, + modified_at, + metadata, + }) + }) + .collect() + } +} + impl RemoteSessionFetcher { + const BUNDLED_CLI_FALLBACK: &'static str = + "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI"; + + pub fn new(per_host_timeout: Duration) -> Self { + Self { per_host_timeout } + } + + pub async fn fetch(&self, hosts: &[String]) -> Vec { + let valid = Self::sanitized_hosts(hosts); + let valid_keys = valid + .iter() + .map(|host| host.to_ascii_lowercase()) + .collect::>(); + let mut invalid = hosts + .iter() + .filter(|host| { + Self::validate_host(host).is_err() + && !valid_keys.contains(&host.trim().to_ascii_lowercase()) + }) + .map(|_| { + AgentSessionHostResult::failed( + "", + "Invalid SSH host entry; use a host name or user@host without spaces or options.", + ) + }) + .collect::>(); + let timeout = self.per_host_timeout; + let mut results = Self::fetch_hosts_with(&valid, |host| async move { + Self::fetch_host(host, timeout).await + }) + .await; + results.append(&mut invalid); + results.sort_by(|lhs, rhs| { + lhs.host + .to_ascii_lowercase() + .cmp(&rhs.host.to_ascii_lowercase()) + }); + results + } + + async fn fetch_host(host: String, timeout: Duration) -> AgentSessionHostResult { + let options = match Self::ssh_options(&host, timeout) { + Ok(options) => options, + Err(error) => return AgentSessionHostResult::failed("", error), + }; + let result = CommandRunner::new().run_async("ssh", None, &options).await; + match result { + Ok(result) if result.timed_out => AgentSessionHostResult::failed( + host, + "SSH session discovery timed out; verify the host is reachable and key authentication is configured.", + ), + Ok(result) if result.exit_code == Some(0) => { + Self::decode_remote_sessions(&host, &result.text).unwrap_or_else(|error| { + AgentSessionHostResult::failed( + host, + actionable_message( + "Remote session response was not valid JSON; update CodexBar on the remote host", + error, + ), + ) + }) + } + Ok(result) => AgentSessionHostResult::failed( + host, + format!( + "SSH session discovery failed{}; verify BatchMode key access and the remote codexbar installation.", + result + .exit_code + .map(|code| format!(" with exit code {code}")) + .unwrap_or_default() + ), + ), + Err(error) => AgentSessionHostResult::failed( + host, + actionable_message( + "Unable to start SSH; install the Windows OpenSSH client and verify PATH", + error, + ), + ), + } + } + + fn ssh_options(host: &str, timeout: Duration) -> Result { + let host = Self::validate_host(host)?; + let connect_timeout = timeout.as_secs().clamp(1, 3); + let remote_command = format!( + "codexbar sessions --json || '{}' sessions --json", + Self::BUNDLED_CLI_FALLBACK + ); + Ok(CommandOptions { + timeout, + initial_delay: Duration::ZERO, + extra_args: vec![ + "-o".to_string(), + "BatchMode=yes".to_string(), + "-o".to_string(), + format!("ConnectTimeout={connect_timeout}"), + "--".to_string(), + host, + "sh".to_string(), + "-lc".to_string(), + remote_command, + ], + ..CommandOptions::default() + }) + } + + async fn fetch_hosts_with(hosts: &[String], fetch: F) -> Vec + where + F: Fn(String) -> Fut + Clone, + Fut: Future, + { + let mut results = join_all(hosts.iter().cloned().map(|host| fetch.clone()(host))).await; + results.sort_by(|lhs, rhs| { + lhs.host + .to_ascii_lowercase() + .cmp(&rhs.host.to_ascii_lowercase()) + }); + results + } + + fn decode_remote_sessions(host: &str, body: &str) -> Result { + if let Ok(mut sessions) = serde_json::from_str::>(body) { + for session in &mut sessions { + session.host = host.to_string(); + } + return Ok(AgentSessionHostResult::success(host, sessions)); + } + let mut result = Self::decode_host_result(body)?; + result.host = host.to_string(); + for session in &mut result.sessions { + session.host = host.to_string(); + } + Ok(result) + } + pub fn sanitized_hosts(hosts: &[String]) -> Vec { let mut seen = HashSet::new(); let mut sanitized = Vec::new(); @@ -694,6 +1436,7 @@ impl RemoteSessionFetcher { } pub fn validate_host(host: &str) -> Result { + let host = host.trim(); if host.is_empty() { return Err("host must not be empty".to_string()); } @@ -727,12 +1470,46 @@ impl RemoteSessionFetcher { } } +impl Default for RemoteSessionFetcher { + fn default() -> Self { + Self { + per_host_timeout: Duration::from_secs(5), + } + } +} + +impl AgentSessionDiscovery { + pub fn new(local: LocalAgentSessionScanner, remote: RemoteSessionFetcher) -> Self { + Self { local, remote } + } + + pub async fn scan(&self, mode: AgentSessionDiscoveryMode) -> AgentSessionDiscoveryResult { + let AgentSessionDiscoveryMode::Enabled { ssh_hosts } = mode else { + return AgentSessionDiscoveryResult::Disabled; + }; + let (local, remote) = tokio::join!(self.local.scan(), self.remote.fetch(&ssh_hosts)); + let mut hosts = Vec::with_capacity(remote.len() + 1); + hosts.push(local); + hosts.extend(remote); + AgentSessionDiscoveryResult::Hosts(hosts) + } +} + +impl Default for AgentSessionDiscovery { + fn default() -> Self { + Self::new( + LocalAgentSessionScanner::default(), + RemoteSessionFetcher::default(), + ) + } +} + fn actionable_message(label: &str, err: impl std::fmt::Display) -> String { crate::logging::safe_error_message(format!("{label}: {err}")) } fn is_safe_host_char(c: char) -> bool { - c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']' | '_') + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']' | '_' | '@') } fn project_name_from_cwd(cwd: &str) -> Option { @@ -750,6 +1527,8 @@ fn project_name_from_cwd(cwd: &str) -> Option { mod tests { use super::*; use chrono::TimeZone; + use std::io; + use std::sync::Arc; #[test] fn process_parser_filters_helpers_app_server_duplicates_and_malformed_lines() { @@ -894,4 +1673,202 @@ bad line let decoded: AgentSessionHostResult = serde_json::from_str(&json).unwrap(); assert_eq!(decoded, result); } + + #[test] + fn windows_process_parser_uses_metadata_and_ignores_command_lines() { + let output = r#"[ + { + "ProcessId": 101, + "ParentProcessId": 1, + "CreationDate": "/Date(1783773458193)/", + "Name": "claude.exe", + "ExecutablePath": "C:\\Tools\\claude.exe", + "CommandLine": "claude.exe --api-key super-secret" + }, + { + "ProcessId": 202, + "ParentProcessId": 1, + "CreationDate": "2026-07-12T00:02:03Z", + "Name": "codex.exe", + "ExecutablePath": "C:\\Tools\\codex.exe" + } + ]"#; + + let records = WindowsProcessOutputParser::parse(output); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].provider, Some(AgentSessionProvider::Claude)); + assert_eq!(records[1].provider, Some(AgentSessionProvider::Codex)); + assert!(records[0].started_at.is_some()); + assert_eq!(records[0].executable, "claude.exe"); + assert!(!records[0].executable.contains("super-secret")); + } + + #[test] + fn windows_process_query_never_requests_raw_command_lines() { + let options = LocalAgentSessionScanner::process_options(Duration::from_secs(2)); + let script = options.extra_args.last().unwrap(); + + assert!(script.contains("ProcessId")); + assert!(script.contains("ExecutablePath")); + assert!(!script.contains("CommandLine")); + } + + #[test] + fn codex_discovery_only_builds_today_and_yesterday_directories() { + let now = Utc.with_ymd_and_hms(2026, 7, 12, 1, 2, 3).unwrap(); + let root = Path::new(r"C:\Users\me\.codex\sessions"); + + let directories = LocalAgentSessionScanner::codex_day_directories(root, now.date_naive()); + + assert_eq!( + directories, + vec![ + root.join("2026").join("07").join("12"), + root.join("2026").join("07").join("11"), + ] + ); + } + + #[test] + fn claude_metadata_parser_stops_after_session_and_cwd_are_known() { + struct FirstLineOnly { + bytes: &'static [u8], + offset: usize, + } + + impl Read for FirstLineOnly { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + assert!( + self.offset < self.bytes.len(), + "parser read past complete metadata" + ); + buffer[0] = self.bytes[self.offset]; + self.offset += 1; + Ok(1) + } + } + + let input = FirstLineOnly { + bytes: + b"{\"type\":\"user\",\"sessionId\":\"session-1\",\"cwd\":\"C:\\\\work\\\\proj\"}\n", + offset: 0, + }; + + let metadata = ClaudeTranscriptMetadataParser::parse(input).unwrap(); + + assert_eq!(metadata.session_id.as_deref(), Some("session-1")); + assert_eq!(metadata.cwd.as_deref(), Some(r"C:\work\proj")); + } + + #[test] + fn focus_rejects_remote_and_file_only_targets_explicitly() { + let remote = AgentSession { + id: "remote".to_string(), + provider: AgentSessionProvider::Codex, + source: AgentSessionSource::Cli, + state: AgentSessionState::Active, + pid: Some(42), + transcript_path: None, + host: "other-host".to_string(), + workspace: AgentSessionWorkspace { + cwd: None, + project_name: None, + }, + activity: AgentSessionActivity { + started_at: None, + last_activity_at: None, + }, + focus_target: AgentSessionFocusTarget::Process { pid: 42 }, + }; + let file_only = AgentSession { + host: "localhost".to_string(), + focus_target: AgentSessionFocusTarget::Transcript { + transcript_path: "session.jsonl".to_string(), + }, + ..remote.clone() + }; + + assert!(matches!( + focus_session(&remote), + SessionFocusResult::Unsupported { .. } + )); + assert!(matches!( + focus_session(&file_only), + SessionFocusResult::Unsupported { .. } + )); + } + + #[test] + fn ssh_fetch_uses_noninteractive_options_and_a_strict_timeout() { + let options = + RemoteSessionFetcher::ssh_options("user@devbox", Duration::from_secs(5)).unwrap(); + + assert_eq!(options.timeout, Duration::from_secs(5)); + assert!( + options + .extra_args + .windows(2) + .any(|pair| pair == ["-o", "BatchMode=yes"]) + ); + assert!( + options + .extra_args + .windows(2) + .any(|pair| pair == ["-o", "ConnectTimeout=3"]) + ); + assert!(options.extra_args.iter().any(|arg| arg == "user@devbox")); + } + + #[tokio::test] + async fn ssh_hosts_are_fetched_in_parallel_and_failures_are_isolated() { + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let results = tokio::time::timeout( + Duration::from_secs(1), + RemoteSessionFetcher::fetch_hosts_with( + &[ + "slow-a".to_string(), + "slow-b".to_string(), + "broken".to_string(), + ], + |host| { + let barrier = Arc::clone(&barrier); + async move { + if host == "broken" { + return AgentSessionHostResult::failed(host, "unreachable"); + } + barrier.wait().await; + AgentSessionHostResult::success(host, Vec::new()) + } + }, + ), + ) + .await + .expect("hosts were fetched sequentially"); + + assert_eq!(results.len(), 3); + assert_eq!( + results + .iter() + .filter(|result| result.error.is_some()) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| result.error.is_none()) + .count(), + 2 + ); + } + + #[tokio::test] + async fn disabled_discovery_returns_without_running_scanners() { + let result = AgentSessionDiscovery::default() + .scan(AgentSessionDiscoveryMode::Disabled) + .await; + + assert_eq!(result, AgentSessionDiscoveryResult::Disabled); + } } diff --git a/rust/src/cli/mod.rs b/rust/src/cli/mod.rs index d6e1dc1f4d..6ff1864d43 100755 --- a/rust/src/cli/mod.rs +++ b/rust/src/cli/mod.rs @@ -14,6 +14,7 @@ pub mod config; pub mod cost; pub mod diagnose; pub mod serve; +pub mod sessions; pub mod tty_runner; pub mod usage; @@ -117,6 +118,9 @@ pub enum Commands { /// Export safe provider diagnostics as JSON Diagnose(diagnose::DiagnoseArgs), + /// List or focus local and configured remote agent sessions + Sessions(sessions::SessionsArgs), + /// Serve usage and cost JSON on 127.0.0.1 Serve(serve::ServeArgs), diff --git a/rust/src/cli/sessions.rs b/rust/src/cli/sessions.rs new file mode 100644 index 0000000000..7005011761 --- /dev/null +++ b/rust/src/cli/sessions.rs @@ -0,0 +1,196 @@ +#[cfg(test)] +mod tests { + use super::*; + use crate::agent_sessions::{ + AgentSession, AgentSessionActivity, AgentSessionFocusTarget, AgentSessionProvider, + AgentSessionSource, AgentSessionState, AgentSessionWorkspace, + }; + + fn sample() -> AgentSession { + AgentSession { + id: "session-1".into(), + provider: AgentSessionProvider::Codex, + source: AgentSessionSource::Cli, + state: AgentSessionState::Active, + pid: Some(42), + transcript_path: None, + host: "DESKTOP".into(), + workspace: AgentSessionWorkspace { + cwd: Some(r"C:\work\demo".into()), + project_name: Some("demo".into()), + }, + activity: AgentSessionActivity { + started_at: None, + last_activity_at: None, + }, + focus_target: AgentSessionFocusTarget::Process { pid: 42 }, + } + } + + #[test] + fn brief_output_is_one_safe_line() { + let line = render_brief(&sample()); + assert_eq!(line, "active codex cli DESKTOP demo (session-1)"); + assert!(!line.contains("C:\\work")); + } + + #[test] + fn json_output_contains_typed_session_fields() { + let output = serde_json::to_string(&sample()).expect("session JSON"); + assert!(output.contains("\"provider\":\"codex\"")); + assert!(output.contains("\"focusTarget\"")); + assert!(!output.contains("rawCommand")); + } +} +use crate::agent_sessions::{ + AgentSession, AgentSessionDiscovery, AgentSessionDiscoveryMode, AgentSessionDiscoveryResult, + SessionFocusResult, focus_session, +}; +use clap::Args; +use serde::Serialize; + +#[derive(Args, Debug, Default)] +pub struct SessionsArgs { + /// Emit machine-readable session data. + #[arg(long)] + pub json: bool, + + /// Pretty-print JSON output. + #[arg(long)] + pub pretty: bool, + + /// Print one compact line per session. + #[arg(long)] + pub brief: bool, + + /// Discover sessions on a configured SSH host (repeatable or comma-separated). + #[arg(long = "ssh-host", value_delimiter = ',')] + pub ssh_hosts: Vec, + + /// Focus one session by its stable id. + #[arg(long)] + pub focus: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SessionsOutput<'a> { + sessions: &'a [AgentSession], + errors: Vec<&'a str>, +} + +pub async fn run(args: SessionsArgs) -> anyhow::Result<()> { + let discovery = AgentSessionDiscovery::default(); + let result = discovery + .scan(AgentSessionDiscoveryMode::Enabled { + ssh_hosts: args.ssh_hosts, + }) + .await; + let sessions = match &result { + AgentSessionDiscoveryResult::Disabled => Vec::new(), + AgentSessionDiscoveryResult::Hosts(hosts) => hosts + .iter() + .flat_map(|host| host.sessions.iter().cloned()) + .collect::>(), + }; + let errors = match &result { + AgentSessionDiscoveryResult::Disabled => Vec::new(), + AgentSessionDiscoveryResult::Hosts(hosts) => hosts + .iter() + .filter_map(|host| host.error.as_deref()) + .collect::>(), + }; + + if let Some(id) = args.focus.as_deref() { + let outcome = sessions + .iter() + .find(|session| session.id == id) + .map(focus_session) + .unwrap_or_else(|| SessionFocusResult::failed("Session was not found.")); + if args.json { + println!( + "{}", + if args.pretty { + serde_json::to_string_pretty(&outcome)? + } else { + serde_json::to_string(&outcome)? + } + ); + } else { + print_focus_result(id, &outcome); + } + return Ok(()); + } + + if args.json { + let output = serde_json::to_string_pretty(&SessionsOutput { + sessions: &sessions, + errors, + })?; + println!( + "{}", + if args.pretty { + output + } else { + serde_json::to_string(&SessionsOutput { + sessions: &sessions, + errors, + })? + } + ); + } else if args.brief { + for session in &sessions { + println!("{}", render_brief(session)); + } + for error in errors { + eprintln!("session discovery: {error}"); + } + } else { + for session in &sessions { + println!( + "{} {} on {} ({})", + provider_label(session), + session.workspace.project_name.as_deref().unwrap_or("unknown workspace"), + session.host, + session.id + ); + } + for error in errors { + eprintln!("session discovery: {error}"); + } + } + + Ok(()) +} + +fn print_focus_result(id: &str, result: &SessionFocusResult) { + match result { + SessionFocusResult::Focused => println!("focused {id}"), + SessionFocusResult::Unsupported { message } | SessionFocusResult::Failed { message } => { + eprintln!("unable to focus {id}: {message}") + } + } +} + +fn render_brief(session: &AgentSession) -> String { + format!( + "{} {} {} {} {} ({})", + format!("{:?}", session.state).to_ascii_lowercase(), + provider_label(session), + format!("{:?}", session.source).to_ascii_lowercase(), + session.host, + session + .workspace + .project_name + .as_deref() + .unwrap_or("unknown"), + session.id + ) +} + +fn provider_label(session: &AgentSession) -> &'static str { + match session.provider { + crate::agent_sessions::AgentSessionProvider::Codex => "codex", + crate::agent_sessions::AgentSessionProvider::Claude => "claude", + } +} diff --git a/rust/src/host/command_runner.rs b/rust/src/host/command_runner.rs index 01b6da70d3..a5ec96e35b 100755 --- a/rust/src/host/command_runner.rs +++ b/rust/src/host/command_runner.rs @@ -11,7 +11,8 @@ use std::io::{BufRead, BufReader}; #[cfg(windows)] use std::os::windows::process::CommandExt; use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, ChildStderr, ChildStdout, Command, Stdio}; +use std::sync::mpsc::{self, RecvTimeoutError}; use std::time::{Duration, Instant}; /// Command runner configuration @@ -143,13 +144,12 @@ impl CommandRunner { let start = Instant::now(); let deadline = start + options.timeout; - Self::send_initial_input(&mut child, input, options.initial_delay); + Self::send_initial_input(&mut child, input, options.initial_delay, deadline); // Capture output - let output = self.capture_output(&mut child, options, deadline)?; + let (output, timed_out) = self.capture_output(&mut child, options, deadline)?; let exit_code = Self::finish_child(&mut child); - let timed_out = Instant::now() >= deadline && output.is_empty(); Ok(CommandResult { text: output, @@ -222,7 +222,12 @@ impl CommandRunner { #[cfg(not(windows))] fn hide_windows_console(_cmd: &mut Command) {} - fn send_initial_input(child: &mut Child, input: Option<&str>, initial_delay: Duration) { + fn send_initial_input( + child: &mut Child, + input: Option<&str>, + initial_delay: Duration, + deadline: Instant, + ) { let Some(input_text) = input else { return; }; @@ -231,7 +236,10 @@ impl CommandRunner { }; use std::io::Write; - std::thread::sleep(initial_delay); + std::thread::sleep(initial_delay.min(deadline.saturating_duration_since(Instant::now()))); + if Self::past_deadline(deadline) { + return; + } let _ = stdin.write_all(input_text.as_bytes()); let _ = stdin.write_all(b"\n"); let _ = stdin.flush(); @@ -255,39 +263,54 @@ impl CommandRunner { child: &mut Child, options: &CommandOptions, deadline: Instant, - ) -> Result { + ) -> Result<(String, bool), CommandError> { let mut output = String::new(); - #[allow(unused_assignments)] let mut last_output_time = Instant::now(); - let reader = Self::stdout_reader(child)?; + let (sender, receiver) = mpsc::channel(); + Self::read_stream(Self::stdout_reader(child)?, sender.clone(), true); + Self::read_stream(Self::stderr_reader(child)?, sender, false); + let mut closed_streams = 0; - for line_result in reader.lines() { + loop { if Self::past_deadline(deadline) { + return Ok((output, true)); + } + if Self::idle_timed_out(options.idle_timeout, last_output_time) { break; } - match line_result { - Ok(line) => { - Self::append_output_line(&mut output, &line); + let wait = Self::next_wait(deadline, options.idle_timeout, last_output_time); + match receiver.recv_timeout(wait) { + Ok(StreamEvent::Line { text, capture }) => { last_output_time = Instant::now(); - - if Self::should_stop_after_line(&line, options) { - std::thread::sleep(options.settle_after_stop); + if !capture { + continue; + } + Self::append_output_line(&mut output, &text); + if Self::should_stop_after_line(&text, options) { + std::thread::sleep( + options + .settle_after_stop + .min(deadline.saturating_duration_since(Instant::now())), + ); break; } } - Err(_) => break, - } - - if Self::idle_timed_out(options.idle_timeout, last_output_time) { - break; + Ok(StreamEvent::Closed) => { + closed_streams += 1; + if closed_streams == 2 { + break; + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, } } - Ok(output) + Ok((output, false)) } - fn stdout_reader(child: &mut Child) -> Result, CommandError> { + fn stdout_reader(child: &mut Child) -> Result, CommandError> { let stdout = child .stdout .take() @@ -296,6 +319,48 @@ impl CommandRunner { Ok(BufReader::new(stdout)) } + fn stderr_reader(child: &mut Child) -> Result, CommandError> { + let stderr = child + .stderr + .take() + .ok_or_else(|| CommandError::IoError("Failed to capture stderr".to_string()))?; + + Ok(BufReader::new(stderr)) + } + + fn read_stream( + reader: BufReader, + sender: mpsc::Sender, + capture: bool, + ) { + std::thread::spawn(move || { + for line in reader.lines().map_while(Result::ok) { + if sender + .send(StreamEvent::Line { + text: line, + capture, + }) + .is_err() + { + return; + } + } + let _ = sender.send(StreamEvent::Closed); + }); + } + + fn next_wait( + deadline: Instant, + idle_timeout: Option, + last_output_time: Instant, + ) -> Duration { + let deadline_wait = deadline.saturating_duration_since(Instant::now()); + let idle_wait = idle_timeout + .map(|timeout| timeout.saturating_sub(last_output_time.elapsed())) + .unwrap_or(deadline_wait); + deadline_wait.min(idle_wait).min(Duration::from_millis(25)) + } + fn past_deadline(deadline: Instant) -> bool { Instant::now() >= deadline } @@ -351,6 +416,11 @@ impl Default for CommandRunner { } } +enum StreamEvent { + Line { text: String, capture: bool }, + Closed, +} + /// Rolling buffer for substring matching across chunk boundaries pub struct RollingBuffer { max_needle_len: usize, @@ -431,6 +501,33 @@ mod tests { assert_eq!(runner.env_additions.get("BAZ"), Some(&"qux".to_string())); } + #[cfg(windows)] + #[test] + fn command_timeout_is_a_wall_clock_bound_even_without_output() { + let runner = CommandRunner::new(); + let options = CommandOptions { + timeout: Duration::from_millis(100), + initial_delay: Duration::ZERO, + extra_args: vec![ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 5".to_string(), + ], + ..CommandOptions::default() + }; + let started = Instant::now(); + + let result = runner.run("powershell.exe", None, &options).unwrap(); + + assert!(result.timed_out); + assert!( + started.elapsed() < Duration::from_secs(2), + "timeout took {:?}", + started.elapsed() + ); + } + #[test] fn test_error_display() { let err = CommandError::BinaryNotFound("codex".to_string()); diff --git a/rust/src/main.rs b/rust/src/main.rs index 0832889872..0becdaab3a 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -96,6 +96,7 @@ fn dispatch_command(rt: &Runtime, command: Option) -> i32 { Some(Commands::Usage(args)) => run_categorized(rt, cli::usage::run(args)), Some(Commands::Cost(args)) => run_categorized(rt, cli::cost::run(args)), Some(Commands::Diagnose(args)) => run_categorized(rt, cli::diagnose::run(args)), + Some(Commands::Sessions(args)) => run_categorized(rt, cli::sessions::run(args)), Some(Commands::Serve(args)) => run_unexpected(rt, cli::serve::run(args)), Some(Commands::Autostart(args)) => run_unexpected(rt, cli::autostart::run(args)), Some(Commands::Account(args)) => run_unexpected(rt, cli::account::run(args)), From e313e81e9965bfc3b66c1ecfb06a2d10b2bb0bf6 Mon Sep 17 00:00:00 2001 From: NessZerra Date: Sun, 12 Jul 2026 09:39:21 +0700 Subject: [PATCH 3/6] Add agent sessions to desktop Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src-tauri/src/commands/agent_sessions.rs | 23 +++ .../src-tauri/src/commands/bridge.rs | 4 + .../src-tauri/src/commands/mod.rs | 2 + .../src-tauri/src/commands/settings.rs | 9 + apps/desktop-tauri/src-tauri/src/main.rs | 2 + .../src/components/AgentSessions.tsx | 65 +++++++ apps/desktop-tauri/src/i18n/keys.ts | 7 + apps/desktop-tauri/src/lib/tauri.ts | 13 ++ apps/desktop-tauri/src/styles.css | 29 +++ apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 3 + .../surfaces/settings/tabs/AdvancedTab.tsx | 45 +++++ apps/desktop-tauri/src/types/bridge.ts | 41 ++++ rust/src/agent_sessions.rs | 179 +++++++----------- rust/src/cli/sessions.rs | 17 +- rust/src/host/command_runner.rs | 24 ++- rust/src/locale.rs | 7 + rust/src/locale/en-US.ftl | 7 + rust/src/locale/es-MX.ftl | 7 + rust/src/locale/ja-JP.ftl | 7 + rust/src/locale/ko-KR.ftl | 7 + rust/src/locale/zh-CN.ftl | 7 + rust/src/locale/zh-TW.ftl | 7 + rust/src/settings.rs | 10 + rust/src/settings/raw.rs | 6 + 24 files changed, 406 insertions(+), 122 deletions(-) create mode 100644 apps/desktop-tauri/src-tauri/src/commands/agent_sessions.rs create mode 100644 apps/desktop-tauri/src/components/AgentSessions.tsx diff --git a/apps/desktop-tauri/src-tauri/src/commands/agent_sessions.rs b/apps/desktop-tauri/src-tauri/src/commands/agent_sessions.rs new file mode 100644 index 0000000000..b5e1862a49 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/agent_sessions.rs @@ -0,0 +1,23 @@ +use codexbar::agent_sessions::{ + AgentSession, AgentSessionDiscovery, AgentSessionDiscoveryMode, AgentSessionDiscoveryResult, + SessionFocusResult, focus_session, +}; +use codexbar::settings::Settings; + +#[tauri::command] +pub async fn list_agent_sessions() -> AgentSessionDiscoveryResult { + let settings = Settings::load(); + let mode = if settings.agent_sessions_enabled { + AgentSessionDiscoveryMode::Enabled { + ssh_hosts: settings.agent_session_ssh_hosts, + } + } else { + AgentSessionDiscoveryMode::Disabled + }; + AgentSessionDiscovery::default().scan(mode).await +} + +#[tauri::command] +pub fn focus_agent_session(session: AgentSession) -> SessionFocusResult { + focus_session(&session) +} diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 71f6fec62f..d23cb73b18 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -430,6 +430,8 @@ pub struct SettingsSnapshot { install_updates_on_quit: bool, global_shortcut: String, codex_custom_sessions_dirs: Vec, + agent_sessions_enabled: bool, + agent_session_ssh_hosts: Vec, ui_language: &'static str, theme: &'static str, window_scale_percent: u16, @@ -519,6 +521,8 @@ impl From for SettingsSnapshot { install_updates_on_quit: settings.install_updates_on_quit, global_shortcut: settings.global_shortcut, codex_custom_sessions_dirs: settings.codex_custom_sessions_dirs, + agent_sessions_enabled: settings.agent_sessions_enabled, + agent_session_ssh_hosts: settings.agent_session_ssh_hosts, ui_language: language_label(settings.ui_language), theme: theme_label(settings.theme), window_scale_percent: settings.window_scale_percent, diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index d74b4e75b9..ce9136fb83 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -28,6 +28,7 @@ mod diagnostics; mod tokens; mod updater; +mod agent_sessions; mod bridge; mod browser_import; mod credential_detection; @@ -41,6 +42,7 @@ mod shortcuts; mod surface; mod system; +pub use agent_sessions::*; pub(crate) use bridge::*; pub use browser_import::*; pub use credential_detection::*; diff --git a/apps/desktop-tauri/src-tauri/src/commands/settings.rs b/apps/desktop-tauri/src-tauri/src/commands/settings.rs index ee8f4949c8..a4a38d8f6a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/settings.rs @@ -34,6 +34,8 @@ pub struct SettingsUpdate { pub install_updates_on_quit: Option, pub global_shortcut: Option, pub codex_custom_sessions_dirs: Option>, + pub agent_sessions_enabled: Option, + pub agent_session_ssh_hosts: Option>, pub ui_language: Option, pub theme: Option, pub window_scale_percent: Option, @@ -225,6 +227,13 @@ impl SettingsUpdate { if let Some(v) = self.codex_custom_sessions_dirs.clone() { settings.codex_custom_sessions_dirs = normalize_custom_sessions_dirs(v); } + if let Some(v) = self.agent_sessions_enabled { + settings.agent_sessions_enabled = v; + } + if let Some(v) = self.agent_session_ssh_hosts.clone() { + settings.agent_session_ssh_hosts = + codexbar::agent_sessions::RemoteSessionFetcher::sanitized_hosts(&v); + } if let Some(v) = self.install_updates_on_quit { settings.install_updates_on_quit = v; } diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 89a81e169a..fa01230d20 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -136,6 +136,8 @@ fn main() { commands::get_bootstrap_state, commands::get_provider_catalog, commands::get_settings_snapshot, + commands::list_agent_sessions, + commands::focus_agent_session, commands::update_settings, commands::set_surface_mode, commands::dismiss_tray_panel, diff --git a/apps/desktop-tauri/src/components/AgentSessions.tsx b/apps/desktop-tauri/src/components/AgentSessions.tsx new file mode 100644 index 0000000000..31118c02dd --- /dev/null +++ b/apps/desktop-tauri/src/components/AgentSessions.tsx @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from "react"; +import { focusAgentSession, listAgentSessions } from "../lib/tauri"; +import type { + AgentSession, + AgentSessionDiscoveryResult, +} from "../types/bridge"; +import { useLocale } from "../hooks/useLocale"; + +export default function AgentSessions() { + const { t } = useLocale(); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(() => { + setError(null); + void listAgentSessions() + .then(setResult) + .catch((reason: unknown) => + setError(reason instanceof Error ? reason.message : String(reason)), + ); + }, []); + + useEffect(refresh, [refresh]); + + if (result?.status === "disabled") return null; + const hosts = result?.status === "hosts" ? result.hosts : []; + const sessions = hosts.flatMap((host) => host.sessions); + const hostErrors = hosts.flatMap((host) => + host.error ? [`${host.host}: ${host.error}`] : [], + ); + + const focus = (session: AgentSession) => { + void focusAgentSession(session).then((focusResult) => { + if (focusResult.status !== "focused") setError(focusResult.message); + }); + }; + + return ( +
+
+ {t("AgentSessionsTitle")} + +
+ {!result &&

{t("AgentSessionsLoading")}

} + {result && sessions.length === 0 && ( +

{t("AgentSessionsEmpty")}

+ )} + {sessions.map((session) => ( + + ))} + {[...hostErrors, ...(error ? [error] : [])].map((message) => ( +

{message}

+ ))} +
+ ); +} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 581e71416d..448b3a1df0 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -278,6 +278,13 @@ export const ALL_LOCALE_KEYS = [ "SectionLocalIntegrations", "PowerToysPipeLabel", "PowerToysPipeHelper", + "AgentSessionsTitle", + "AgentSessionsEnableLabel", + "AgentSessionsEnableHelper", + "AgentSessionsSshHostsLabel", + "AgentSessionsSshHostsHelper", + "AgentSessionsLoading", + "AgentSessionsEmpty", "UpdatesTitle", "UpdateChannelChoice", "UpdateChannelChoiceHelper", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 67fd09155b..900089cf4e 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -30,6 +30,9 @@ import type { SafeDiagnostics, CredentialStorageStatus, WorkAreaRect, + AgentSession, + AgentSessionDiscoveryResult, + SessionFocusResult, } from "../types/bridge"; export function getBootstrapState(): Promise { @@ -54,6 +57,16 @@ export function updateSettings( return invoke("update_settings", { patch }); } +export function listAgentSessions(): Promise { + return invoke("list_agent_sessions"); +} + +export function focusAgentSession( + session: AgentSession, +): Promise { + return invoke("focus_agent_session", { session }); +} + export function setSurfaceMode( mode: M, target: SurfaceTargetForMode, diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 8572f9ff08..30357f1c25 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -5557,3 +5557,32 @@ html:has(.menu-surface--tray) { padding-bottom: 3px; } } +.agent-sessions { + display: grid; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid var(--panel-border); + font-size: 12px; +} + +.agent-sessions__header, +.agent-sessions__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.agent-sessions__row { + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 6px; + color: inherit; + background: var(--surface-elevated); + cursor: pointer; +} + +.agent-sessions__error { + color: var(--provider-status-error); +} diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index d0d1579051..a3cac29459 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -32,6 +32,7 @@ import { hydrateProviderSlots, orderedEnabledProviderSlots, } from "../lib/trayProviders"; +import AgentSessions from "../components/AgentSessions"; /** Provider IDs that have a dashboard URL in the backend */ const HAS_DASHBOARD = new Set([ @@ -455,6 +456,7 @@ export default function TrayPanel({ state }: { state: BootstrapState }) { footerRows={footerRows} style={{ zoom: trayScale }} > + {settings.agentSessionsEnabled && } + {settings.agentSessionsEnabled && } host.trim()).filter(Boolean); +} + export default function AdvancedTab({ settings, set, saving }: TabProps) { const { t } = useLocale(); const [shortcutError, setShortcutError] = useState(null); const [codexDirsDraft, setCodexDirsDraft] = useState(() => formatCodexSessionsDirs(settings.codexCustomSessionsDirs), ); + const [sshHostsDraft, setSshHostsDraft] = useState(() => + (settings.agentSessionSshHosts ?? []).join(", "), + ); useEffect(() => { if (!saving) { @@ -32,6 +39,10 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { } }, [saving, settings.codexCustomSessionsDirs]); + useEffect(() => { + if (!saving) setSshHostsDraft((settings.agentSessionSshHosts ?? []).join(", ")); + }, [saving, settings.agentSessionSshHosts]); + const commitShortcut = useCallback( async (accelerator: string) => { setShortcutError(null); @@ -114,6 +125,40 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) { + {/* -- Privacy ----------------------------------------------- */} +
+

{t("AgentSessionsTitle")}

+
+ + set({ agentSessionsEnabled: v })} + /> + + + setSshHostsDraft(event.target.value)} + onBlur={() => set({ agentSessionSshHosts: parseSshHosts(sshHostsDraft) })} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + +
+
+ {/* -- Privacy ----------------------------------------------- */}

{t("PrivacyTitle")}

diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 9daeee6a65..5e0eae2d9a 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -126,6 +126,43 @@ export interface CurrentSurfaceState { target: SurfaceTarget; } +export interface AgentSession { + id: string; + provider: "codex" | "claude"; + source: "cli" | "desktopApp" | "ide" | "unknown"; + state: "active" | "idle"; + pid: number | null; + transcriptPath: string | null; + host: string; + workspace: { + cwd: string | null; + projectName: string | null; + }; + activity: { + startedAt: string | null; + lastActivityAt: string | null; + }; + focusTarget: + | { kind: "process"; pid: number } + | { kind: "transcript"; transcriptPath: string } + | { kind: "none" }; +} + +export interface AgentSessionHostResult { + host: string; + sessions: AgentSession[]; + error: string | null; +} + +export type AgentSessionDiscoveryResult = + | { status: "disabled" } + | { status: "hosts"; hosts: AgentSessionHostResult[] }; + +export type SessionFocusResult = + | { status: "focused" } + | { status: "unsupported"; message: string } + | { status: "failed"; message: string }; + export interface ProofRect { x: number; y: number; @@ -195,6 +232,8 @@ export interface SettingsSnapshot { globalShortcut: string; /** Extra Codex home or sessions directories scanned for local cost estimates. */ codexCustomSessionsDirs: string[]; + agentSessionsEnabled?: boolean; + agentSessionSshHosts?: string[]; uiLanguage: Language; theme: ThemePreference; /** 100..=250 — clamped server-side. */ @@ -252,6 +291,8 @@ export interface SettingsUpdate { installUpdatesOnQuit?: boolean; globalShortcut?: string; codexCustomSessionsDirs?: string[]; + agentSessionsEnabled?: boolean; + agentSessionSshHosts?: string[]; uiLanguage?: Language; theme?: ThemePreference; windowScalePercent?: number; diff --git a/rust/src/agent_sessions.rs b/rust/src/agent_sessions.rs index 5d54387bf7..86106bad4e 100644 --- a/rust/src/agent_sessions.rs +++ b/rust/src/agent_sessions.rs @@ -70,35 +70,6 @@ pub struct AgentSession { pub focus_target: AgentSessionFocusTarget, } -impl AgentSession { - #[allow(clippy::too_many_arguments)] - pub fn new( - id: impl Into, - provider: AgentSessionProvider, - source: AgentSessionSource, - state: AgentSessionState, - pid: Option, - transcript_path: Option, - host: impl Into, - workspace: AgentSessionWorkspace, - activity: AgentSessionActivity, - focus_target: AgentSessionFocusTarget, - ) -> Self { - Self { - id: id.into(), - provider, - source, - state, - pid, - transcript_path, - host: host.into(), - workspace, - activity, - focus_target, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionHostResult { @@ -304,7 +275,8 @@ pub enum AgentSessionDiscoveryMode { Enabled { ssh_hosts: Vec }, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "status", content = "hosts")] pub enum AgentSessionDiscoveryResult { Disabled, Hosts(Vec), @@ -766,51 +738,6 @@ impl CodexRolloutFirstLineParser { } Some(line) } - - #[allow(clippy::too_many_arguments)] - pub fn make_session( - metadata: CodexRolloutMetadata, - transcript_path: &Path, - modified_at: DateTime, - pid: Option, - started_at: Option>, - host: &str, - config: SessionScanConfig, - now: DateTime, - ) -> Option { - if pid.is_none() && !config.file_only_session_allowed(modified_at, now) { - return None; - } - - let session_source = metadata.session_source(); - let workspace = AgentSessionWorkspace { - cwd: metadata.cwd.clone(), - project_name: metadata.cwd.as_deref().and_then(project_name_from_cwd), - }; - let transcript_path = transcript_path.to_string_lossy().to_string(); - let focus_target = match pid { - Some(pid) => AgentSessionFocusTarget::Process { pid }, - None => AgentSessionFocusTarget::Transcript { - transcript_path: transcript_path.clone(), - }, - }; - - Some(AgentSession::new( - metadata.session_id, - AgentSessionProvider::Codex, - session_source, - config.state(Some(modified_at), now, pid.is_some()), - pid, - Some(transcript_path), - host, - workspace, - AgentSessionActivity { - started_at, - last_activity_at: Some(modified_at), - }, - focus_target, - )) - } } impl AgentSessionCorrelation { @@ -821,9 +748,9 @@ impl AgentSessionCorrelation { pub fn focus_session(session: &AgentSession) -> SessionFocusResult { match session.focus_target { - AgentSessionFocusTarget::Transcript { .. } => { - SessionFocusResult::unsupported("This file-only session has no focusable Windows window.") - } + AgentSessionFocusTarget::Transcript { .. } => SessionFocusResult::unsupported( + "This file-only session has no focusable Windows window.", + ), AgentSessionFocusTarget::None => { SessionFocusResult::unsupported("This session has no focus target on Windows.") } @@ -867,10 +794,10 @@ fn focus_process(pid: u32) -> SessionFocusResult { } unsafe extern "system" fn find_window(hwnd: HWND, data: LPARAM) -> BOOL { - let search = &mut *(data.0 as *mut Search); + let search = unsafe { &mut *(data.0 as *mut Search) }; let mut window_pid = 0; - GetWindowThreadProcessId(hwnd, Some(&mut window_pid)); - if window_pid == search.pid && IsWindowVisible(hwnd).as_bool() { + unsafe { GetWindowThreadProcessId(hwnd, Some(&mut window_pid)) }; + if window_pid == search.pid && unsafe { IsWindowVisible(hwnd).as_bool() } { search.window = Some(hwnd); return BOOL(0); } @@ -1033,18 +960,11 @@ impl LocalAgentSessionScanner { } } - sessions.extend(rollouts.into_iter().filter_map(|rollout| { - CodexRolloutFirstLineParser::make_session( - rollout.metadata, - &rollout.path, - rollout.modified_at, - None, - None, - host, - self.config, - now, - ) - })); + sessions.extend( + rollouts + .into_iter() + .filter_map(|rollout| self.codex_file_session(host, now, rollout)), + ); sessions.sort_by(|lhs, rhs| { (rhs.state == AgentSessionState::Active) .cmp(&(lhs.state == AgentSessionState::Active)) @@ -1079,27 +999,27 @@ impl LocalAgentSessionScanner { let transcript_path = rollout .as_ref() .map(|rollout| rollout.path.to_string_lossy().to_string()); - AgentSession::new( - rollout + AgentSession { + id: rollout .as_ref() .map(|rollout| rollout.metadata.session_id.clone()) .unwrap_or_else(|| format!("pid:{}", process.pid)), - AgentSessionProvider::Codex, + provider: AgentSessionProvider::Codex, source, - self.config.state(modified_at, now, true), - Some(process.pid), + state: self.config.state(modified_at, now, true), + pid: Some(process.pid), transcript_path, - host, - AgentSessionWorkspace { + host: host.to_string(), + workspace: AgentSessionWorkspace { project_name: cwd.as_deref().and_then(project_name_from_cwd), cwd, }, - AgentSessionActivity { + activity: AgentSessionActivity { started_at: process.started_at, last_activity_at: modified_at, }, - AgentSessionFocusTarget::Process { pid: process.pid }, - ) + focus_target: AgentSessionFocusTarget::Process { pid: process.pid }, + } } fn claude_process_session( @@ -1129,24 +1049,57 @@ impl LocalAgentSessionScanner { }) }) .unwrap_or_else(|| format!("pid:{}", process.pid)); - AgentSession::new( + AgentSession { id, - AgentSessionProvider::Claude, - process.source, - self.config.state(modified_at, now, true), - Some(process.pid), + provider: AgentSessionProvider::Claude, + source: process.source, + state: self.config.state(modified_at, now, true), + pid: Some(process.pid), transcript_path, - host, - AgentSessionWorkspace { + host: host.to_string(), + workspace: AgentSessionWorkspace { project_name: cwd.as_deref().and_then(project_name_from_cwd), cwd, }, - AgentSessionActivity { + activity: AgentSessionActivity { started_at: process.started_at, last_activity_at: modified_at, }, - AgentSessionFocusTarget::Process { pid: process.pid }, - ) + focus_target: AgentSessionFocusTarget::Process { pid: process.pid }, + } + } + + fn codex_file_session( + &self, + host: &str, + now: DateTime, + rollout: CodexRollout, + ) -> Option { + self.config + .file_only_session_allowed(rollout.modified_at, now) + .then(|| { + let source = rollout.metadata.session_source(); + let cwd = rollout.metadata.cwd; + let transcript_path = rollout.path.to_string_lossy().to_string(); + AgentSession { + id: rollout.metadata.session_id, + provider: AgentSessionProvider::Codex, + source, + state: self.config.state(Some(rollout.modified_at), now, false), + pid: None, + transcript_path: Some(transcript_path.clone()), + host: host.to_string(), + workspace: AgentSessionWorkspace { + project_name: cwd.as_deref().and_then(project_name_from_cwd), + cwd, + }, + activity: AgentSessionActivity { + started_at: None, + last_activity_at: Some(rollout.modified_at), + }, + focus_target: AgentSessionFocusTarget::Transcript { transcript_path }, + } + }) } fn codex_sessions_root() -> PathBuf { diff --git a/rust/src/cli/sessions.rs b/rust/src/cli/sessions.rs index 7005011761..fb369dc494 100644 --- a/rust/src/cli/sessions.rs +++ b/rust/src/cli/sessions.rs @@ -123,19 +123,16 @@ pub async fn run(args: SessionsArgs) -> anyhow::Result<()> { } if args.json { - let output = serde_json::to_string_pretty(&SessionsOutput { + let output = SessionsOutput { sessions: &sessions, errors, - })?; + }; println!( "{}", if args.pretty { - output + serde_json::to_string_pretty(&output)? } else { - serde_json::to_string(&SessionsOutput { - sessions: &sessions, - errors, - })? + serde_json::to_string(&output)? } ); } else if args.brief { @@ -150,7 +147,11 @@ pub async fn run(args: SessionsArgs) -> anyhow::Result<()> { println!( "{} {} on {} ({})", provider_label(session), - session.workspace.project_name.as_deref().unwrap_or("unknown workspace"), + session + .workspace + .project_name + .as_deref() + .unwrap_or("unknown workspace"), session.host, session.id ); diff --git a/rust/src/host/command_runner.rs b/rust/src/host/command_runner.rs index a5ec96e35b..4e900ab294 100755 --- a/rust/src/host/command_runner.rs +++ b/rust/src/host/command_runner.rs @@ -109,6 +109,7 @@ pub struct CommandRunner { } impl CommandRunner { + const MAX_CAPTURE_BYTES: usize = 1024 * 1024; pub fn new() -> Self { Self { env_additions: HashMap::new(), @@ -366,7 +367,20 @@ impl CommandRunner { } fn append_output_line(output: &mut String, line: &str) { - output.push_str(line); + if output.len() >= Self::MAX_CAPTURE_BYTES { + return; + } + let remaining = Self::MAX_CAPTURE_BYTES - output.len(); + let end = line + .char_indices() + .take_while(|(index, _)| *index < remaining) + .map(|(index, character)| index + character.len_utf8()) + .last() + .unwrap_or(0); + output.push_str(&line[..end]); + if output.len() >= Self::MAX_CAPTURE_BYTES { + return; + } output.push('\n'); } @@ -501,6 +515,14 @@ mod tests { assert_eq!(runner.env_additions.get("BAZ"), Some(&"qux".to_string())); } + #[test] + fn captured_output_is_bounded() { + let mut output = String::new(); + let line = "x".repeat(CommandRunner::MAX_CAPTURE_BYTES + 10); + CommandRunner::append_output_line(&mut output, &line); + assert_eq!(output.len(), CommandRunner::MAX_CAPTURE_BYTES); + } + #[cfg(windows)] #[test] fn command_timeout_is_a_wall_clock_bound_even_without_output() { diff --git a/rust/src/locale.rs b/rust/src/locale.rs index acb2a5f589..ba3bb09d64 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -494,6 +494,13 @@ locale_keys! { SectionLocalIntegrations, PowerToysPipeLabel, PowerToysPipeHelper, + AgentSessionsTitle, + AgentSessionsEnableLabel, + AgentSessionsEnableHelper, + AgentSessionsSshHostsLabel, + AgentSessionsSshHostsHelper, + AgentSessionsLoading, + AgentSessionsEmpty, UpdatesTitle, UpdateChannelChoice, UpdateChannelChoiceHelper, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 44e9d76598..60bc49f659 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -271,6 +271,13 @@ HidePersonalInfoHelper = Mask emails and account names (good for streaming) SectionLocalIntegrations = Local integrations PowerToysPipeLabel = PowerToys status pipe PowerToysPipeHelper = Expose cached provider status and local usage totals to PowerToys Command Palette through a named pipe. No emails or plans are included. Restart required. +AgentSessionsTitle = Agent Sessions +AgentSessionsEnableLabel = Show active Codex and Claude sessions +AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. +AgentSessionsSshHostsLabel = SSH hosts +AgentSessionsSshHostsHelper = Comma-separated SSH targets. Connections use non-interactive mode and short timeouts. +AgentSessionsLoading = Looking for agent sessions... +AgentSessionsEmpty = No active agent sessions. UpdatesTitle = Updates UpdateChannelChoice = Update Channel UpdateChannelChoiceHelper = Choose between stable and beta preview versions diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 9d5c6f71be..0613daf453 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -265,6 +265,13 @@ HidePersonalInfoHelper = Ocultar correos y nombres de cuenta (útil para transmi SectionLocalIntegrations = Integraciones locales PowerToysPipeLabel = Canal de estado de PowerToys PowerToysPipeHelper = Exponer el estado de proveedores en cache y totales de uso local a PowerToys Command Palette mediante un canal con nombre. No incluye correos ni planes. Requiere reiniciar. +AgentSessionsTitle = Agent Sessions +AgentSessionsEnableLabel = Show active Codex and Claude sessions +AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. +AgentSessionsSshHostsLabel = SSH hosts +AgentSessionsSshHostsHelper = Comma-separated SSH targets. Connections use non-interactive mode and short timeouts. +AgentSessionsLoading = Looking for agent sessions... +AgentSessionsEmpty = No active agent sessions. UpdatesTitle = Actualizaciones UpdateChannelChoice = Canal de actualización UpdateChannelChoiceHelper = Elegir entre versiones estables y versiones beta de prueba diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 1227b5c17a..acb4e8986b 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -265,6 +265,13 @@ HidePersonalInfoHelper = Mask emails and account names (good for streaming) SectionLocalIntegrations = ローカル連携 PowerToysPipeLabel = PowerToys ステータスパイプ PowerToysPipeHelper = 名前付きパイプ経由で、キャッシュ済みのプロバイダーステータスとローカル使用量の合計を PowerToys Command Palette に公開します。メールやプランは含まれません。再起動が必要です。 +AgentSessionsTitle = Agent Sessions +AgentSessionsEnableLabel = Show active Codex and Claude sessions +AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. +AgentSessionsSshHostsLabel = SSH hosts +AgentSessionsSshHostsHelper = Comma-separated SSH targets. Connections use non-interactive mode and short timeouts. +AgentSessionsLoading = Looking for agent sessions... +AgentSessionsEmpty = No active agent sessions. UpdatesTitle = Updates UpdateChannelChoice = Update Channel UpdateChannelChoiceHelper = Choose between stable and beta preview versions diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 94e08bce2b..fa87f6436e 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -265,6 +265,13 @@ HidePersonalInfoHelper = 이메일 및 계정 이름 마스킹 (스트리밍 시 SectionLocalIntegrations = 로컬 통합 PowerToysPipeLabel = PowerToys 상태 파이프 PowerToysPipeHelper = 명명된 파이프를 통해 캐시된 공급자 상태와 로컬 사용량 합계를 PowerToys Command Palette에 노출합니다. 이메일이나 플랜은 포함되지 않습니다. 재시작이 필요합니다. +AgentSessionsTitle = Agent Sessions +AgentSessionsEnableLabel = Show active Codex and Claude sessions +AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. +AgentSessionsSshHostsLabel = SSH hosts +AgentSessionsSshHostsHelper = Comma-separated SSH targets. Connections use non-interactive mode and short timeouts. +AgentSessionsLoading = Looking for agent sessions... +AgentSessionsEmpty = No active agent sessions. UpdatesTitle = 업데이트 UpdateChannelChoice = 업데이트 채널 UpdateChannelChoiceHelper = 안정 버전과 베타 미리보기 버전 중 선택 diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index f9496fa578..331a9f8456 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -265,6 +265,13 @@ HidePersonalInfoHelper = 遮蔽邮箱和账号名称(适合直播时使用) SectionLocalIntegrations = 本机集成 PowerToysPipeLabel = PowerToys 状态管道 PowerToysPipeHelper = 通过命名管道向 PowerToys Command Palette 暴露已缓存的提供商状态和本地用量汇总。不包含邮箱或套餐。需要重启后生效。 +AgentSessionsTitle = Agent Sessions +AgentSessionsEnableLabel = Show active Codex and Claude sessions +AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. +AgentSessionsSshHostsLabel = SSH hosts +AgentSessionsSshHostsHelper = Comma-separated SSH targets. Connections use non-interactive mode and short timeouts. +AgentSessionsLoading = Looking for agent sessions... +AgentSessionsEmpty = No active agent sessions. UpdatesTitle = 更新 UpdateChannelChoice = 更新通道 UpdateChannelChoiceHelper = 在稳定版与测试预览版之间选择 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 02ce3757d3..fe3b2c0097 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -265,6 +265,13 @@ HidePersonalInfoHelper = 遮蔽郵箱和賬號名稱(適合直播時使用) SectionLocalIntegrations = 本機整合 PowerToysPipeLabel = PowerToys 狀態管道 PowerToysPipeHelper = 透過具名管道向 PowerToys Command Palette 公開已快取的供應商狀態與本機使用量總計。不包含電子郵件或方案資訊。需要重新啟動。 +AgentSessionsTitle = Agent Sessions +AgentSessionsEnableLabel = Show active Codex and Claude sessions +AgentSessionsEnableHelper = Discover local sessions and optional SSH hosts. Disabled by default. +AgentSessionsSshHostsLabel = SSH hosts +AgentSessionsSshHostsHelper = Comma-separated SSH targets. Connections use non-interactive mode and short timeouts. +AgentSessionsLoading = Looking for agent sessions... +AgentSessionsEmpty = No active agent sessions. UpdatesTitle = 更新 UpdateChannelChoice = 更新通道 UpdateChannelChoiceHelper = 在穩定版與測試預覽版之間選擇 diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 30ae306127..e440c85860 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -145,6 +145,14 @@ pub struct Settings { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub codex_custom_sessions_dirs: Vec, + /// Discover local and configured SSH Codex/Claude sessions. + #[serde(default)] + pub agent_sessions_enabled: bool, + + /// SSH targets queried for remote agent sessions. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_session_ssh_hosts: Vec, + /// Automatically download updates in the background #[serde(default)] pub auto_download_updates: bool, @@ -377,6 +385,8 @@ impl Default for Settings { provider_order: Vec::new(), // Empty = canonical ProviderId::all() order global_shortcut: default_global_shortcut(), // Ctrl+Shift+U by default codex_custom_sessions_dirs: Vec::new(), + agent_sessions_enabled: false, + agent_session_ssh_hosts: Vec::new(), auto_download_updates: false, // Require explicit opt-in for background downloads install_updates_on_quit: false, // Don't auto-install on quit by default ui_language: Language::default(), // English by default diff --git a/rust/src/settings/raw.rs b/rust/src/settings/raw.rs index 64ef568752..4e2c2a5574 100644 --- a/rust/src/settings/raw.rs +++ b/rust/src/settings/raw.rs @@ -107,6 +107,8 @@ pub(super) struct RawSettings { #[serde(default = "default_global_shortcut")] global_shortcut: String, codex_custom_sessions_dirs: Vec, + agent_sessions_enabled: bool, + agent_session_ssh_hosts: Vec, auto_download_updates: bool, install_updates_on_quit: bool, ui_language: Language, @@ -200,6 +202,8 @@ impl Default for RawSettings { provider_order: s.provider_order, global_shortcut: s.global_shortcut, codex_custom_sessions_dirs: s.codex_custom_sessions_dirs, + agent_sessions_enabled: s.agent_sessions_enabled, + agent_session_ssh_hosts: s.agent_session_ssh_hosts, auto_download_updates: s.auto_download_updates, install_updates_on_quit: s.install_updates_on_quit, ui_language: s.ui_language, @@ -461,6 +465,8 @@ impl From for Settings { }, global_shortcut: raw.global_shortcut, codex_custom_sessions_dirs: raw.codex_custom_sessions_dirs, + agent_sessions_enabled: raw.agent_sessions_enabled, + agent_session_ssh_hosts: raw.agent_session_ssh_hosts, auto_download_updates: raw.auto_download_updates, install_updates_on_quit: raw.install_updates_on_quit, ui_language: raw.ui_language, From 0cc0e8a10022235a1a7ae714ab6ee04eeb2e7f28 Mon Sep 17 00:00:00 2001 From: NessZerra Date: Sun, 12 Jul 2026 09:44:11 +0700 Subject: [PATCH 4/6] Split agent session discovery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- rust/src/agent_sessions.rs | 886 +---------------------------- rust/src/agent_sessions/focus.rs | 83 +++ rust/src/agent_sessions/parsers.rs | 453 +++++++++++++++ rust/src/agent_sessions/tests.rs | 349 ++++++++++++ 4 files changed, 889 insertions(+), 882 deletions(-) create mode 100644 rust/src/agent_sessions/focus.rs create mode 100644 rust/src/agent_sessions/parsers.rs create mode 100644 rust/src/agent_sessions/tests.rs diff --git a/rust/src/agent_sessions.rs b/rust/src/agent_sessions.rs index 86106bad4e..a1b744bcaa 100644 --- a/rust/src/agent_sessions.rs +++ b/rust/src/agent_sessions.rs @@ -294,539 +294,9 @@ pub struct AgentSessionDiscovery { remote: RemoteSessionFetcher, } -impl AgentPSOutputParser { - pub fn parse(output: &str) -> Vec { - let mut seen_pids = HashSet::new(); - output - .lines() - .filter_map(|line| Self::parse_line(line, &mut seen_pids)) - .collect() - } - - pub fn agent_processes(records: &[AgentProcessRecord]) -> Vec { - let mut seen = HashSet::new(); - records - .iter() - .filter(|record| record.is_agent()) - .filter_map(|record| { - if seen.insert(record.pid) { - Some(record.clone()) - } else { - None - } - }) - .collect() - } - - pub fn provider(record: &AgentProcessRecord) -> Option { - record.provider - } - - pub fn source(record: &AgentProcessRecord) -> AgentSessionSource { - record.source - } - - pub fn has_codex_app_server(records: &[AgentProcessRecord]) -> bool { - records.iter().any(|record| { - record.kind == AgentProcessKind::AppServer - && record.provider == Some(AgentSessionProvider::Codex) - }) - } - - fn parse_line(line: &str, seen_pids: &mut HashSet) -> Option { - let mut fields = line.split_whitespace(); - let pid = fields.next()?.parse::().ok()?; - let ppid = fields.next()?.parse::().ok()?; - let weekday = fields.next()?; - let month = fields.next()?; - let day = fields.next()?; - let time = fields.next()?; - let year = fields.next()?; - if !seen_pids.insert(pid) { - return None; - } - - let started_at = Self::parse_started_at(weekday, month, day, time, year)?; - let command = fields.collect::>().join(" "); - let classification = classify_process_command(&command); - Some(AgentProcessRecord { - pid, - ppid, - started_at: Some(started_at), - provider: classification.provider, - source: classification.source, - executable: classification.executable, - kind: classification.kind, - }) - } - - fn parse_started_at( - weekday: &str, - month: &str, - day: &str, - time: &str, - year: &str, - ) -> Option> { - let text = format!("{weekday} {month} {day} {time} {year}"); - chrono::NaiveDateTime::parse_from_str(&text, "%a %b %e %H:%M:%S %Y") - .ok() - .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) - } -} - -#[derive(Deserialize)] -#[serde(rename_all = "PascalCase")] -struct WindowsProcessMetadata { - process_id: u32, - #[serde(default)] - parent_process_id: u32, - creation_date: Option, - name: Option, - executable_path: Option, -} - -impl WindowsProcessOutputParser { - pub fn parse(output: &str) -> Vec { - let Ok(value) = serde_json::from_str::(output.trim()) else { - return Vec::new(); - }; - let values = match value { - Value::Array(values) => values, - Value::Object(_) => vec![value], - _ => return Vec::new(), - }; - let mut seen = HashSet::new(); - - values - .into_iter() - .filter_map(|value| serde_json::from_value::(value).ok()) - .filter(|process| process.process_id > 0 && seen.insert(process.process_id)) - .map(|process| { - let display_name = process - .name - .as_deref() - .filter(|name| !name.trim().is_empty()) - .or(process.executable_path.as_deref()) - .unwrap_or_default(); - let classification = classify_process_command(display_name); - AgentProcessRecord { - pid: process.process_id, - ppid: process.parent_process_id, - started_at: process - .creation_date - .as_deref() - .and_then(parse_windows_creation_date), - provider: classification.provider, - source: classification.source, - executable: process.name.unwrap_or(classification.executable), - kind: classification.kind, - } - }) - .collect() - } -} - -fn parse_windows_creation_date(value: &str) -> Option> { - DateTime::parse_from_rfc3339(value) - .ok() - .map(|date| date.with_timezone(&Utc)) - .or_else(|| { - let value = value.strip_prefix("/Date(")?; - let milliseconds = value - .trim_end_matches(")/") - .split(['+', '-']) - .next()? - .parse() - .ok()?; - DateTime::::from_timestamp_millis(milliseconds) - }) - .or_else(|| { - let core = value.get(..21)?; - chrono::NaiveDateTime::parse_from_str(core, "%Y%m%d%H%M%S%.f") - .ok() - .map(|date| DateTime::::from_naive_utc_and_offset(date, Utc)) - }) -} - -struct ProcessClassification { - provider: Option, - source: AgentSessionSource, - executable: String, - kind: AgentProcessKind, -} - -fn classify_process_command(command: &str) -> ProcessClassification { - let lower = command.to_ascii_lowercase(); - let executable = executable_basename(command); - - if lower.contains("app-server") && lower.contains("codex") { - return ProcessClassification { - provider: Some(AgentSessionProvider::Codex), - source: AgentSessionSource::DesktopApp, - executable, - kind: AgentProcessKind::AppServer, - }; - } - - if lower.contains("codex (renderer)") - || lower.contains("claude-code-acp") - || lower.contains("--help") - || lower.contains("--version") - || lower.contains("--type=renderer") - || lower.contains("disclaimer") - || executable.eq_ignore_ascii_case("disclaimer") - { - return ProcessClassification { - provider: None, - source: AgentSessionSource::Unknown, - executable, - kind: AgentProcessKind::Helper, - }; - } - - if lower.contains("application support/claude/claude-code/claude") - || lower.contains("claude.app") - || lower.contains("claude.exe") - || executable.eq_ignore_ascii_case("claude") - { - return ProcessClassification { - provider: Some(AgentSessionProvider::Claude), - source: if lower.contains("application support/claude/claude-code") - || lower.contains("claude.app") - { - AgentSessionSource::DesktopApp - } else { - AgentSessionSource::Cli - }, - executable: if executable.eq_ignore_ascii_case("claude") { - "claude".to_string() - } else { - executable - }, - kind: AgentProcessKind::Agent, - }; - } - - if lower.contains("codex.exe") - || lower.contains("codex.app") - || lower.contains("codex desktop") - || executable.eq_ignore_ascii_case("codex") - { - return ProcessClassification { - provider: Some(AgentSessionProvider::Codex), - source: if lower.contains("codex.app") || lower.contains("codex desktop") { - AgentSessionSource::DesktopApp - } else { - AgentSessionSource::Cli - }, - executable: if executable.eq_ignore_ascii_case("codex") { - "codex".to_string() - } else { - executable - }, - kind: AgentProcessKind::Agent, - }; - } - - ProcessClassification { - provider: None, - source: AgentSessionSource::Unknown, - executable, - kind: AgentProcessKind::Other, - } -} - -fn executable_basename(command: &str) -> String { - let normalized = command.replace('\\', "/").to_ascii_lowercase(); - for (needle, name) in [ - ("claude-code-acp", "claude-code-acp"), - ("application support/claude/claude-code/claude", "claude"), - ("codex app-server", "codex"), - ("codex (renderer)", "codex"), - ("codex.app", "codex"), - ("claude.app", "claude"), - ("claude.exe", "claude"), - ("codex.exe", "codex"), - ("disclaimer", "disclaimer"), - ] { - if normalized.contains(needle) { - return name.to_string(); - } - } - - let first_token = command.split_whitespace().next().unwrap_or_default(); - if first_token.is_empty() { - return String::new(); - } - - Path::new(first_token) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(first_token) - .to_string() -} - -impl LSOFCWDOutputParser { - pub fn parse(output: &str) -> HashMap { - let mut result = HashMap::new(); - let mut current_pid = None; - - for line in output.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - - match line.chars().next() { - Some('p') => { - current_pid = line[1..].trim().parse::().ok(); - } - Some('n') => { - if let Some(pid) = current_pid { - result.insert(pid, line[1..].to_string()); - } - } - _ => {} - } - } - - result - } -} - -impl ClaudeSessionProjectMapper { - pub fn escaped_cwd(cwd: &str) -> String { - cwd.chars() - .map(|scalar| { - if scalar.is_ascii_alphanumeric() { - scalar - } else { - '-' - } - }) - .collect() - } - - pub fn project_directories(cwd: &str, home_directory: &Path) -> Vec { - if cwd.trim().is_empty() { - return Vec::new(); - } - - vec![ - home_directory - .join(".claude") - .join("projects") - .join(Self::escaped_cwd(cwd)), - ] - } - - pub fn transcripts(cwd: &str, home_directory: &Path) -> Vec { - let mut transcripts = Vec::new(); - - for directory in Self::project_directories(cwd, home_directory) { - let Ok(entries) = fs::read_dir(&directory) else { - continue; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { - continue; - } - - let Ok(metadata) = entry.metadata() else { - continue; - }; - let Ok(modified) = metadata.modified() else { - continue; - }; - - transcripts.push(ClaudeTranscript::new(path, modified.into())); - } - } - - transcripts.sort_by(|lhs, rhs| { - rhs.modified_at - .cmp(&lhs.modified_at) - .then_with(|| rhs.url.cmp(&lhs.url)) - }); - transcripts - } - - pub fn newest_transcript(cwd: &str, home_directory: &Path) -> Option { - Self::transcripts(cwd, home_directory).into_iter().next() - } -} - -impl ClaudeTranscriptMetadataParser { - const MAX_LINES: usize = 32; - const MAX_BYTES: u64 = 64 * 1024; - - pub fn parse(reader: impl Read) -> Option { - let mut session_id = None; - let mut cwd = None; - let reader = BufReader::new(reader.take(Self::MAX_BYTES)); - - for line in reader.lines().take(Self::MAX_LINES).map_while(Result::ok) { - let Ok(value) = serde_json::from_str::(&line) else { - continue; - }; - if session_id.is_none() { - session_id = value - .get("sessionId") - .or_else(|| value.get("session_id")) - .and_then(Value::as_str) - .map(str::to_owned); - } - if cwd.is_none() { - cwd = value.get("cwd").and_then(Value::as_str).map(str::to_owned); - } - if session_id.is_some() && cwd.is_some() { - break; - } - } - - (session_id.is_some() || cwd.is_some()) - .then_some(ClaudeTranscriptMetadata { session_id, cwd }) - } -} - -impl CodexRolloutFirstLineParser { - pub fn parse(line: &str) -> Option { - let value: Value = serde_json::from_str(line).ok()?; - if value.get("type")?.as_str()? != "session_meta" { - return None; - } - - let payload = value.get("payload")?.as_object()?; - let session_id = payload - .get("session_id") - .or_else(|| payload.get("id"))? - .as_str()?; - let session_id = session_id.trim(); - if session_id.is_empty() { - return None; - } - - Some(CodexRolloutMetadata { - session_id: session_id.to_string(), - cwd: payload - .get("cwd") - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned), - originator: payload - .get("originator") - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned), - source: payload - .get("source") - .and_then(|value| value.as_str()) - .map(ToOwned::to_owned), - }) - } - - pub fn read_first_line(path: &Path) -> Option { - let file = File::open(path).ok()?; - let mut reader = BufReader::new(file); - let mut line = String::new(); - let bytes = reader.read_line(&mut line).ok()?; - if bytes == 0 { - return None; - } - while line.ends_with(['\n', '\r']) { - line.pop(); - } - Some(line) - } -} - -impl AgentSessionCorrelation { - pub fn project_name(cwd: Option<&str>) -> Option { - cwd.and_then(project_name_from_cwd) - } -} - -pub fn focus_session(session: &AgentSession) -> SessionFocusResult { - match session.focus_target { - AgentSessionFocusTarget::Transcript { .. } => SessionFocusResult::unsupported( - "This file-only session has no focusable Windows window.", - ), - AgentSessionFocusTarget::None => { - SessionFocusResult::unsupported("This session has no focus target on Windows.") - } - AgentSessionFocusTarget::Process { pid } => { - if !is_local_host(&session.host) { - return SessionFocusResult::unsupported( - "Remote session focus is not supported from this Windows desktop.", - ); - } - - focus_process(pid) - } - } -} - -fn is_local_host(host: &str) -> bool { - if host.eq_ignore_ascii_case("localhost") - || host == "127.0.0.1" - || host == "::1" - || host.is_empty() - { - return true; - } - - std::env::var("COMPUTERNAME") - .map(|name| name.eq_ignore_ascii_case(host)) - .unwrap_or(false) -} - -#[cfg(windows)] -fn focus_process(pid: u32) -> SessionFocusResult { - use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; - use windows::Win32::UI::WindowsAndMessaging::{ - EnumWindows, GetWindowThreadProcessId, IsWindowVisible, SW_RESTORE, SetForegroundWindow, - ShowWindow, - }; - - struct Search { - pid: u32, - window: Option, - } - - unsafe extern "system" fn find_window(hwnd: HWND, data: LPARAM) -> BOOL { - let search = unsafe { &mut *(data.0 as *mut Search) }; - let mut window_pid = 0; - unsafe { GetWindowThreadProcessId(hwnd, Some(&mut window_pid)) }; - if window_pid == search.pid && unsafe { IsWindowVisible(hwnd).as_bool() } { - search.window = Some(hwnd); - return BOOL(0); - } - BOOL(1) - } - - let mut search = Search { pid, window: None }; - let result = unsafe { EnumWindows(Some(find_window), LPARAM(&mut search as *mut _ as isize)) }; - if result.is_err() { - return SessionFocusResult::failed("Windows could not enumerate application windows."); - } - - let Some(window) = search.window else { - return SessionFocusResult::failed("No focusable window was found for this session."); - }; - unsafe { - let _ = ShowWindow(window, SW_RESTORE); - if SetForegroundWindow(window).as_bool() { - SessionFocusResult::focused() - } else { - SessionFocusResult::failed("Windows denied the request to focus this session.") - } - } -} - -#[cfg(not(windows))] -fn focus_process(_pid: u32) -> SessionFocusResult { - SessionFocusResult::unsupported("Process focus requires the Windows desktop shell.") -} +mod focus; +mod parsers; +pub use focus::focus_session; struct CodexRollout { path: PathBuf, @@ -1476,352 +946,4 @@ fn project_name_from_cwd(cwd: &str) -> Option { } } -#[cfg(test)] -mod tests { - use super::*; - use chrono::TimeZone; - use std::io; - use std::sync::Arc; - - #[test] - fn process_parser_filters_helpers_app_server_duplicates_and_malformed_lines() { - let output = "\ -101 1 Mon Jul 6 09:00:00 2026 /Applications/Claude.app/Contents/Resources/disclaimer /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions -102 101 Mon Jul 6 09:00:01 2026 /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions -102 101 Mon Jul 6 09:00:01 2026 /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions -201 1 Mon Jul 6 09:01:00 2026 /opt/homebrew/bin/codex exec --full-auto strange argv here -202 1 Mon Jul 6 09:02:00 2026 /Applications/Codex.app/Contents/Resources/codex app-server --listen stdio -203 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/codex --help -301 1 Mon Jul 6 09:04:00 2026 /Users/test/.local/bin/claude-code-acp --stdio -401 1 Mon Jul 6 09:05:00 2026 /Applications/Codex.app/Contents/Frameworks/Codex Framework.framework/Helpers/Codex (Renderer) --type=renderer -bad line -"; - - let records = AgentPSOutputParser::parse(output); - let agents = AgentPSOutputParser::agent_processes(&records); - - assert_eq!(agents.len(), 2); - } - - #[test] - fn session_scan_config_cuts_off_active_and_file_only_windows() { - let config = SessionScanConfig::default(); - let now = Utc.with_ymd_and_hms(2026, 7, 12, 0, 0, 0).unwrap(); - - assert_eq!( - config.state(Some(now - chrono::Duration::seconds(119)), now, true), - AgentSessionState::Active - ); - assert_eq!( - config.state(Some(now - chrono::Duration::seconds(121)), now, true), - AgentSessionState::Idle - ); - assert!(config.file_only_session_allowed(now - chrono::Duration::minutes(29), now)); - assert!(!config.file_only_session_allowed(now - chrono::Duration::minutes(31), now)); - } - - #[test] - fn agent_session_round_trips_json() { - let session = AgentSession { - id: "session-1".to_string(), - provider: AgentSessionProvider::Codex, - source: AgentSessionSource::DesktopApp, - state: AgentSessionState::Active, - pid: Some(1234), - transcript_path: Some("C:\\sessions\\rollout.jsonl".to_string()), - host: "devbox".to_string(), - workspace: AgentSessionWorkspace { - cwd: Some("C:\\work\\proj".to_string()), - project_name: Some("proj".to_string()), - }, - activity: AgentSessionActivity { - started_at: Some(Utc.with_ymd_and_hms(2026, 7, 12, 0, 0, 0).unwrap()), - last_activity_at: Some(Utc.with_ymd_and_hms(2026, 7, 12, 0, 1, 0).unwrap()), - }, - focus_target: AgentSessionFocusTarget::Process { pid: 1234 }, - }; - - let json = serde_json::to_string(&session).unwrap(); - let round_tripped: AgentSession = serde_json::from_str(&json).unwrap(); - assert_eq!(round_tripped, session); - assert!(json.contains("\"focusTarget\"")); - } - - #[test] - fn focus_result_serializes_safely() { - let focused = serde_json::to_value(&SessionFocusResult::Focused).unwrap(); - let unsupported = serde_json::to_value(&SessionFocusResult::Unsupported { - message: "focus unavailable".to_string(), - }) - .unwrap(); - let failed = serde_json::to_value(&SessionFocusResult::Failed { - message: "failed to focus".to_string(), - }) - .unwrap(); - - assert!(focused.is_string() || focused.is_object()); - assert_eq!(unsupported["message"], "focus unavailable"); - assert_eq!(failed["message"], "failed to focus"); - } - - #[test] - fn host_validation_dedupes_and_rejects_unsafe_values() { - let hosts = RemoteSessionFetcher::sanitized_hosts(&[ - "".to_string(), - " ".to_string(), - "-bad".to_string(), - "good".to_string(), - "good".to_string(), - "GOOD".to_string(), - "bad host".to_string(), - "bad\tcontrol".to_string(), - ]); - assert_eq!(hosts, vec!["good".to_string()]); - } - - #[test] - fn codex_rollout_parser_reads_first_line_metadata() { - let metadata = CodexRolloutFirstLineParser::parse( - r#"{"type":"session_meta","payload":{"session_id":"abc","cwd":"C:\\work\\proj","originator":"codex_exec","source":"cli"}}"#, - ) - .unwrap(); - assert_eq!(metadata.session_id, "abc"); - assert_eq!(metadata.cwd.as_deref(), Some("C:\\work\\proj")); - } - - #[test] - fn claude_cwd_escape_is_stable() { - assert_eq!( - ClaudeSessionProjectMapper::escaped_cwd(r"C:\Users\me\My Project!"), - "C--Users-me-My-Project-" - ); - } - - #[test] - fn remote_session_result_round_trips() { - let result = AgentSessionHostResult { - host: "devbox".to_string(), - sessions: vec![AgentSession { - id: "session-1".to_string(), - provider: AgentSessionProvider::Claude, - source: AgentSessionSource::Cli, - state: AgentSessionState::Idle, - pid: None, - transcript_path: None, - host: "devbox".to_string(), - workspace: AgentSessionWorkspace { - cwd: None, - project_name: None, - }, - activity: AgentSessionActivity { - started_at: None, - last_activity_at: None, - }, - focus_target: AgentSessionFocusTarget::None, - }], - error: Some("ssh not found".to_string()), - }; - - let json = serde_json::to_string(&result).unwrap(); - let decoded: AgentSessionHostResult = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded, result); - } - - #[test] - fn windows_process_parser_uses_metadata_and_ignores_command_lines() { - let output = r#"[ - { - "ProcessId": 101, - "ParentProcessId": 1, - "CreationDate": "/Date(1783773458193)/", - "Name": "claude.exe", - "ExecutablePath": "C:\\Tools\\claude.exe", - "CommandLine": "claude.exe --api-key super-secret" - }, - { - "ProcessId": 202, - "ParentProcessId": 1, - "CreationDate": "2026-07-12T00:02:03Z", - "Name": "codex.exe", - "ExecutablePath": "C:\\Tools\\codex.exe" - } - ]"#; - - let records = WindowsProcessOutputParser::parse(output); - - assert_eq!(records.len(), 2); - assert_eq!(records[0].provider, Some(AgentSessionProvider::Claude)); - assert_eq!(records[1].provider, Some(AgentSessionProvider::Codex)); - assert!(records[0].started_at.is_some()); - assert_eq!(records[0].executable, "claude.exe"); - assert!(!records[0].executable.contains("super-secret")); - } - - #[test] - fn windows_process_query_never_requests_raw_command_lines() { - let options = LocalAgentSessionScanner::process_options(Duration::from_secs(2)); - let script = options.extra_args.last().unwrap(); - - assert!(script.contains("ProcessId")); - assert!(script.contains("ExecutablePath")); - assert!(!script.contains("CommandLine")); - } - - #[test] - fn codex_discovery_only_builds_today_and_yesterday_directories() { - let now = Utc.with_ymd_and_hms(2026, 7, 12, 1, 2, 3).unwrap(); - let root = Path::new(r"C:\Users\me\.codex\sessions"); - - let directories = LocalAgentSessionScanner::codex_day_directories(root, now.date_naive()); - - assert_eq!( - directories, - vec![ - root.join("2026").join("07").join("12"), - root.join("2026").join("07").join("11"), - ] - ); - } - - #[test] - fn claude_metadata_parser_stops_after_session_and_cwd_are_known() { - struct FirstLineOnly { - bytes: &'static [u8], - offset: usize, - } - - impl Read for FirstLineOnly { - fn read(&mut self, buffer: &mut [u8]) -> io::Result { - assert!( - self.offset < self.bytes.len(), - "parser read past complete metadata" - ); - buffer[0] = self.bytes[self.offset]; - self.offset += 1; - Ok(1) - } - } - - let input = FirstLineOnly { - bytes: - b"{\"type\":\"user\",\"sessionId\":\"session-1\",\"cwd\":\"C:\\\\work\\\\proj\"}\n", - offset: 0, - }; - - let metadata = ClaudeTranscriptMetadataParser::parse(input).unwrap(); - - assert_eq!(metadata.session_id.as_deref(), Some("session-1")); - assert_eq!(metadata.cwd.as_deref(), Some(r"C:\work\proj")); - } - - #[test] - fn focus_rejects_remote_and_file_only_targets_explicitly() { - let remote = AgentSession { - id: "remote".to_string(), - provider: AgentSessionProvider::Codex, - source: AgentSessionSource::Cli, - state: AgentSessionState::Active, - pid: Some(42), - transcript_path: None, - host: "other-host".to_string(), - workspace: AgentSessionWorkspace { - cwd: None, - project_name: None, - }, - activity: AgentSessionActivity { - started_at: None, - last_activity_at: None, - }, - focus_target: AgentSessionFocusTarget::Process { pid: 42 }, - }; - let file_only = AgentSession { - host: "localhost".to_string(), - focus_target: AgentSessionFocusTarget::Transcript { - transcript_path: "session.jsonl".to_string(), - }, - ..remote.clone() - }; - - assert!(matches!( - focus_session(&remote), - SessionFocusResult::Unsupported { .. } - )); - assert!(matches!( - focus_session(&file_only), - SessionFocusResult::Unsupported { .. } - )); - } - - #[test] - fn ssh_fetch_uses_noninteractive_options_and_a_strict_timeout() { - let options = - RemoteSessionFetcher::ssh_options("user@devbox", Duration::from_secs(5)).unwrap(); - - assert_eq!(options.timeout, Duration::from_secs(5)); - assert!( - options - .extra_args - .windows(2) - .any(|pair| pair == ["-o", "BatchMode=yes"]) - ); - assert!( - options - .extra_args - .windows(2) - .any(|pair| pair == ["-o", "ConnectTimeout=3"]) - ); - assert!(options.extra_args.iter().any(|arg| arg == "user@devbox")); - } - - #[tokio::test] - async fn ssh_hosts_are_fetched_in_parallel_and_failures_are_isolated() { - let barrier = Arc::new(tokio::sync::Barrier::new(2)); - let results = tokio::time::timeout( - Duration::from_secs(1), - RemoteSessionFetcher::fetch_hosts_with( - &[ - "slow-a".to_string(), - "slow-b".to_string(), - "broken".to_string(), - ], - |host| { - let barrier = Arc::clone(&barrier); - async move { - if host == "broken" { - return AgentSessionHostResult::failed(host, "unreachable"); - } - barrier.wait().await; - AgentSessionHostResult::success(host, Vec::new()) - } - }, - ), - ) - .await - .expect("hosts were fetched sequentially"); - - assert_eq!(results.len(), 3); - assert_eq!( - results - .iter() - .filter(|result| result.error.is_some()) - .count(), - 1 - ); - assert_eq!( - results - .iter() - .filter(|result| result.error.is_none()) - .count(), - 2 - ); - } - - #[tokio::test] - async fn disabled_discovery_returns_without_running_scanners() { - let result = AgentSessionDiscovery::default() - .scan(AgentSessionDiscoveryMode::Disabled) - .await; - - assert_eq!(result, AgentSessionDiscoveryResult::Disabled); - } -} +include!("agent_sessions/tests.rs"); diff --git a/rust/src/agent_sessions/focus.rs b/rust/src/agent_sessions/focus.rs new file mode 100644 index 0000000000..0b4d937211 --- /dev/null +++ b/rust/src/agent_sessions/focus.rs @@ -0,0 +1,83 @@ +use super::*; + +pub fn focus_session(session: &AgentSession) -> SessionFocusResult { + match session.focus_target { + AgentSessionFocusTarget::Transcript { .. } => SessionFocusResult::unsupported( + "This file-only session has no focusable Windows window.", + ), + AgentSessionFocusTarget::None => { + SessionFocusResult::unsupported("This session has no focus target on Windows.") + } + AgentSessionFocusTarget::Process { pid } => { + if !is_local_host(&session.host) { + return SessionFocusResult::unsupported( + "Remote session focus is not supported from this Windows desktop.", + ); + } + + focus_process(pid) + } + } +} + +fn is_local_host(host: &str) -> bool { + if host.eq_ignore_ascii_case("localhost") + || host == "127.0.0.1" + || host == "::1" + || host.is_empty() + { + return true; + } + + std::env::var("COMPUTERNAME") + .map(|name| name.eq_ignore_ascii_case(host)) + .unwrap_or(false) +} + +#[cfg(windows)] +fn focus_process(pid: u32) -> SessionFocusResult { + use windows::Win32::Foundation::{BOOL, HWND, LPARAM}; + use windows::Win32::UI::WindowsAndMessaging::{ + EnumWindows, GetWindowThreadProcessId, IsWindowVisible, SW_RESTORE, SetForegroundWindow, + ShowWindow, + }; + + struct Search { + pid: u32, + window: Option, + } + + unsafe extern "system" fn find_window(hwnd: HWND, data: LPARAM) -> BOOL { + let search = unsafe { &mut *(data.0 as *mut Search) }; + let mut window_pid = 0; + unsafe { GetWindowThreadProcessId(hwnd, Some(&mut window_pid)) }; + if window_pid == search.pid && unsafe { IsWindowVisible(hwnd).as_bool() } { + search.window = Some(hwnd); + return BOOL(0); + } + BOOL(1) + } + + let mut search = Search { pid, window: None }; + let result = unsafe { EnumWindows(Some(find_window), LPARAM(&mut search as *mut _ as isize)) }; + if result.is_err() { + return SessionFocusResult::failed("Windows could not enumerate application windows."); + } + + let Some(window) = search.window else { + return SessionFocusResult::failed("No focusable window was found for this session."); + }; + unsafe { + let _ = ShowWindow(window, SW_RESTORE); + if SetForegroundWindow(window).as_bool() { + SessionFocusResult::focused() + } else { + SessionFocusResult::failed("Windows denied the request to focus this session.") + } + } +} + +#[cfg(not(windows))] +fn focus_process(_pid: u32) -> SessionFocusResult { + SessionFocusResult::unsupported("Process focus requires the Windows desktop shell.") +} diff --git a/rust/src/agent_sessions/parsers.rs b/rust/src/agent_sessions/parsers.rs new file mode 100644 index 0000000000..b297b889a2 --- /dev/null +++ b/rust/src/agent_sessions/parsers.rs @@ -0,0 +1,453 @@ +use super::*; + +impl AgentPSOutputParser { + pub fn parse(output: &str) -> Vec { + let mut seen_pids = HashSet::new(); + output + .lines() + .filter_map(|line| Self::parse_line(line, &mut seen_pids)) + .collect() + } + + pub fn agent_processes(records: &[AgentProcessRecord]) -> Vec { + let mut seen = HashSet::new(); + records + .iter() + .filter(|record| record.is_agent()) + .filter_map(|record| { + if seen.insert(record.pid) { + Some(record.clone()) + } else { + None + } + }) + .collect() + } + + pub fn provider(record: &AgentProcessRecord) -> Option { + record.provider + } + + pub fn source(record: &AgentProcessRecord) -> AgentSessionSource { + record.source + } + + pub fn has_codex_app_server(records: &[AgentProcessRecord]) -> bool { + records.iter().any(|record| { + record.kind == AgentProcessKind::AppServer + && record.provider == Some(AgentSessionProvider::Codex) + }) + } + + fn parse_line(line: &str, seen_pids: &mut HashSet) -> Option { + let mut fields = line.split_whitespace(); + let pid = fields.next()?.parse::().ok()?; + let ppid = fields.next()?.parse::().ok()?; + let weekday = fields.next()?; + let month = fields.next()?; + let day = fields.next()?; + let time = fields.next()?; + let year = fields.next()?; + if !seen_pids.insert(pid) { + return None; + } + + let started_at = Self::parse_started_at(weekday, month, day, time, year)?; + let command = fields.collect::>().join(" "); + let classification = classify_process_command(&command); + Some(AgentProcessRecord { + pid, + ppid, + started_at: Some(started_at), + provider: classification.provider, + source: classification.source, + executable: classification.executable, + kind: classification.kind, + }) + } + + fn parse_started_at( + weekday: &str, + month: &str, + day: &str, + time: &str, + year: &str, + ) -> Option> { + let text = format!("{weekday} {month} {day} {time} {year}"); + chrono::NaiveDateTime::parse_from_str(&text, "%a %b %e %H:%M:%S %Y") + .ok() + .map(|dt| DateTime::::from_naive_utc_and_offset(dt, Utc)) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct WindowsProcessMetadata { + process_id: u32, + #[serde(default)] + parent_process_id: u32, + creation_date: Option, + name: Option, + executable_path: Option, +} + +impl WindowsProcessOutputParser { + pub fn parse(output: &str) -> Vec { + let Ok(value) = serde_json::from_str::(output.trim()) else { + return Vec::new(); + }; + let values = match value { + Value::Array(values) => values, + Value::Object(_) => vec![value], + _ => return Vec::new(), + }; + let mut seen = HashSet::new(); + + values + .into_iter() + .filter_map(|value| serde_json::from_value::(value).ok()) + .filter(|process| process.process_id > 0 && seen.insert(process.process_id)) + .map(|process| { + let display_name = process + .name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .or(process.executable_path.as_deref()) + .unwrap_or_default(); + let classification = classify_process_command(display_name); + AgentProcessRecord { + pid: process.process_id, + ppid: process.parent_process_id, + started_at: process + .creation_date + .as_deref() + .and_then(parse_windows_creation_date), + provider: classification.provider, + source: classification.source, + executable: process.name.unwrap_or(classification.executable), + kind: classification.kind, + } + }) + .collect() + } +} + +fn parse_windows_creation_date(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|date| date.with_timezone(&Utc)) + .or_else(|| { + let value = value.strip_prefix("/Date(")?; + let milliseconds = value + .trim_end_matches(")/") + .split(['+', '-']) + .next()? + .parse() + .ok()?; + DateTime::::from_timestamp_millis(milliseconds) + }) + .or_else(|| { + let core = value.get(..21)?; + chrono::NaiveDateTime::parse_from_str(core, "%Y%m%d%H%M%S%.f") + .ok() + .map(|date| DateTime::::from_naive_utc_and_offset(date, Utc)) + }) +} + +struct ProcessClassification { + provider: Option, + source: AgentSessionSource, + executable: String, + kind: AgentProcessKind, +} + +fn classify_process_command(command: &str) -> ProcessClassification { + let lower = command.to_ascii_lowercase(); + let executable = executable_basename(command); + + if lower.contains("app-server") && lower.contains("codex") { + return ProcessClassification { + provider: Some(AgentSessionProvider::Codex), + source: AgentSessionSource::DesktopApp, + executable, + kind: AgentProcessKind::AppServer, + }; + } + + if lower.contains("codex (renderer)") + || lower.contains("claude-code-acp") + || lower.contains("--help") + || lower.contains("--version") + || lower.contains("--type=renderer") + || lower.contains("disclaimer") + || executable.eq_ignore_ascii_case("disclaimer") + { + return ProcessClassification { + provider: None, + source: AgentSessionSource::Unknown, + executable, + kind: AgentProcessKind::Helper, + }; + } + + if lower.contains("application support/claude/claude-code/claude") + || lower.contains("claude.app") + || lower.contains("claude.exe") + || executable.eq_ignore_ascii_case("claude") + { + return ProcessClassification { + provider: Some(AgentSessionProvider::Claude), + source: if lower.contains("application support/claude/claude-code") + || lower.contains("claude.app") + { + AgentSessionSource::DesktopApp + } else { + AgentSessionSource::Cli + }, + executable: if executable.eq_ignore_ascii_case("claude") { + "claude".to_string() + } else { + executable + }, + kind: AgentProcessKind::Agent, + }; + } + + if lower.contains("codex.exe") + || lower.contains("codex.app") + || lower.contains("codex desktop") + || executable.eq_ignore_ascii_case("codex") + { + return ProcessClassification { + provider: Some(AgentSessionProvider::Codex), + source: if lower.contains("codex.app") || lower.contains("codex desktop") { + AgentSessionSource::DesktopApp + } else { + AgentSessionSource::Cli + }, + executable: if executable.eq_ignore_ascii_case("codex") { + "codex".to_string() + } else { + executable + }, + kind: AgentProcessKind::Agent, + }; + } + + ProcessClassification { + provider: None, + source: AgentSessionSource::Unknown, + executable, + kind: AgentProcessKind::Other, + } +} + +fn executable_basename(command: &str) -> String { + let normalized = command.replace('\\', "/").to_ascii_lowercase(); + for (needle, name) in [ + ("claude-code-acp", "claude-code-acp"), + ("application support/claude/claude-code/claude", "claude"), + ("codex app-server", "codex"), + ("codex (renderer)", "codex"), + ("codex.app", "codex"), + ("claude.app", "claude"), + ("claude.exe", "claude"), + ("codex.exe", "codex"), + ("disclaimer", "disclaimer"), + ] { + if normalized.contains(needle) { + return name.to_string(); + } + } + + let first_token = command.split_whitespace().next().unwrap_or_default(); + if first_token.is_empty() { + return String::new(); + } + + Path::new(first_token) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(first_token) + .to_string() +} + +impl LSOFCWDOutputParser { + pub fn parse(output: &str) -> HashMap { + let mut result = HashMap::new(); + let mut current_pid = None; + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + match line.chars().next() { + Some('p') => { + current_pid = line[1..].trim().parse::().ok(); + } + Some('n') => { + if let Some(pid) = current_pid { + result.insert(pid, line[1..].to_string()); + } + } + _ => {} + } + } + + result + } +} + +impl ClaudeSessionProjectMapper { + pub fn escaped_cwd(cwd: &str) -> String { + cwd.chars() + .map(|scalar| { + if scalar.is_ascii_alphanumeric() { + scalar + } else { + '-' + } + }) + .collect() + } + + pub fn project_directories(cwd: &str, home_directory: &Path) -> Vec { + if cwd.trim().is_empty() { + return Vec::new(); + } + + vec![ + home_directory + .join(".claude") + .join("projects") + .join(Self::escaped_cwd(cwd)), + ] + } + + pub fn transcripts(cwd: &str, home_directory: &Path) -> Vec { + let mut transcripts = Vec::new(); + + for directory in Self::project_directories(cwd, home_directory) { + let Ok(entries) = fs::read_dir(&directory) else { + continue; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + continue; + } + + let Ok(metadata) = entry.metadata() else { + continue; + }; + let Ok(modified) = metadata.modified() else { + continue; + }; + + transcripts.push(ClaudeTranscript::new(path, modified.into())); + } + } + + transcripts.sort_by(|lhs, rhs| { + rhs.modified_at + .cmp(&lhs.modified_at) + .then_with(|| rhs.url.cmp(&lhs.url)) + }); + transcripts + } + + pub fn newest_transcript(cwd: &str, home_directory: &Path) -> Option { + Self::transcripts(cwd, home_directory).into_iter().next() + } +} + +impl ClaudeTranscriptMetadataParser { + const MAX_LINES: usize = 32; + const MAX_BYTES: u64 = 64 * 1024; + + pub fn parse(reader: impl Read) -> Option { + let mut session_id = None; + let mut cwd = None; + let reader = BufReader::new(reader.take(Self::MAX_BYTES)); + + for line in reader.lines().take(Self::MAX_LINES).map_while(Result::ok) { + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if session_id.is_none() { + session_id = value + .get("sessionId") + .or_else(|| value.get("session_id")) + .and_then(Value::as_str) + .map(str::to_owned); + } + if cwd.is_none() { + cwd = value.get("cwd").and_then(Value::as_str).map(str::to_owned); + } + if session_id.is_some() && cwd.is_some() { + break; + } + } + + (session_id.is_some() || cwd.is_some()) + .then_some(ClaudeTranscriptMetadata { session_id, cwd }) + } +} + +impl CodexRolloutFirstLineParser { + pub fn parse(line: &str) -> Option { + let value: Value = serde_json::from_str(line).ok()?; + if value.get("type")?.as_str()? != "session_meta" { + return None; + } + + let payload = value.get("payload")?.as_object()?; + let session_id = payload + .get("session_id") + .or_else(|| payload.get("id"))? + .as_str()?; + let session_id = session_id.trim(); + if session_id.is_empty() { + return None; + } + + Some(CodexRolloutMetadata { + session_id: session_id.to_string(), + cwd: payload + .get("cwd") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned), + originator: payload + .get("originator") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned), + source: payload + .get("source") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned), + }) + } + + pub fn read_first_line(path: &Path) -> Option { + let file = File::open(path).ok()?; + let mut reader = BufReader::new(file); + let mut line = String::new(); + let bytes = reader.read_line(&mut line).ok()?; + if bytes == 0 { + return None; + } + while line.ends_with(['\n', '\r']) { + line.pop(); + } + Some(line) + } +} + +impl AgentSessionCorrelation { + pub fn project_name(cwd: Option<&str>) -> Option { + cwd.and_then(project_name_from_cwd) + } +} diff --git a/rust/src/agent_sessions/tests.rs b/rust/src/agent_sessions/tests.rs new file mode 100644 index 0000000000..6418efd011 --- /dev/null +++ b/rust/src/agent_sessions/tests.rs @@ -0,0 +1,349 @@ +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + use std::io; + use std::sync::Arc; + + #[test] + fn process_parser_filters_helpers_app_server_duplicates_and_malformed_lines() { + let output = "\ +101 1 Mon Jul 6 09:00:00 2026 /Applications/Claude.app/Contents/Resources/disclaimer /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions +102 101 Mon Jul 6 09:00:01 2026 /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions +102 101 Mon Jul 6 09:00:01 2026 /Users/test/Library/Application Support/Claude/claude-code/claude --dangerously-skip-permissions +201 1 Mon Jul 6 09:01:00 2026 /opt/homebrew/bin/codex exec --full-auto strange argv here +202 1 Mon Jul 6 09:02:00 2026 /Applications/Codex.app/Contents/Resources/codex app-server --listen stdio +203 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/codex --help +301 1 Mon Jul 6 09:04:00 2026 /Users/test/.local/bin/claude-code-acp --stdio +401 1 Mon Jul 6 09:05:00 2026 /Applications/Codex.app/Contents/Frameworks/Codex Framework.framework/Helpers/Codex (Renderer) --type=renderer +bad line +"; + + let records = AgentPSOutputParser::parse(output); + let agents = AgentPSOutputParser::agent_processes(&records); + + assert_eq!(agents.len(), 2); + } + + #[test] + fn session_scan_config_cuts_off_active_and_file_only_windows() { + let config = SessionScanConfig::default(); + let now = Utc.with_ymd_and_hms(2026, 7, 12, 0, 0, 0).unwrap(); + + assert_eq!( + config.state(Some(now - chrono::Duration::seconds(119)), now, true), + AgentSessionState::Active + ); + assert_eq!( + config.state(Some(now - chrono::Duration::seconds(121)), now, true), + AgentSessionState::Idle + ); + assert!(config.file_only_session_allowed(now - chrono::Duration::minutes(29), now)); + assert!(!config.file_only_session_allowed(now - chrono::Duration::minutes(31), now)); + } + + #[test] + fn agent_session_round_trips_json() { + let session = AgentSession { + id: "session-1".to_string(), + provider: AgentSessionProvider::Codex, + source: AgentSessionSource::DesktopApp, + state: AgentSessionState::Active, + pid: Some(1234), + transcript_path: Some("C:\\sessions\\rollout.jsonl".to_string()), + host: "devbox".to_string(), + workspace: AgentSessionWorkspace { + cwd: Some("C:\\work\\proj".to_string()), + project_name: Some("proj".to_string()), + }, + activity: AgentSessionActivity { + started_at: Some(Utc.with_ymd_and_hms(2026, 7, 12, 0, 0, 0).unwrap()), + last_activity_at: Some(Utc.with_ymd_and_hms(2026, 7, 12, 0, 1, 0).unwrap()), + }, + focus_target: AgentSessionFocusTarget::Process { pid: 1234 }, + }; + + let json = serde_json::to_string(&session).unwrap(); + let round_tripped: AgentSession = serde_json::from_str(&json).unwrap(); + assert_eq!(round_tripped, session); + assert!(json.contains("\"focusTarget\"")); + } + + #[test] + fn focus_result_serializes_safely() { + let focused = serde_json::to_value(&SessionFocusResult::Focused).unwrap(); + let unsupported = serde_json::to_value(&SessionFocusResult::Unsupported { + message: "focus unavailable".to_string(), + }) + .unwrap(); + let failed = serde_json::to_value(&SessionFocusResult::Failed { + message: "failed to focus".to_string(), + }) + .unwrap(); + + assert!(focused.is_string() || focused.is_object()); + assert_eq!(unsupported["message"], "focus unavailable"); + assert_eq!(failed["message"], "failed to focus"); + } + + #[test] + fn host_validation_dedupes_and_rejects_unsafe_values() { + let hosts = RemoteSessionFetcher::sanitized_hosts(&[ + "".to_string(), + " ".to_string(), + "-bad".to_string(), + "good".to_string(), + "good".to_string(), + "GOOD".to_string(), + "bad host".to_string(), + "bad\tcontrol".to_string(), + ]); + assert_eq!(hosts, vec!["good".to_string()]); + } + + #[test] + fn codex_rollout_parser_reads_first_line_metadata() { + let metadata = CodexRolloutFirstLineParser::parse( + r#"{"type":"session_meta","payload":{"session_id":"abc","cwd":"C:\\work\\proj","originator":"codex_exec","source":"cli"}}"#, + ) + .unwrap(); + assert_eq!(metadata.session_id, "abc"); + assert_eq!(metadata.cwd.as_deref(), Some("C:\\work\\proj")); + } + + #[test] + fn claude_cwd_escape_is_stable() { + assert_eq!( + ClaudeSessionProjectMapper::escaped_cwd(r"C:\Users\me\My Project!"), + "C--Users-me-My-Project-" + ); + } + + #[test] + fn remote_session_result_round_trips() { + let result = AgentSessionHostResult { + host: "devbox".to_string(), + sessions: vec![AgentSession { + id: "session-1".to_string(), + provider: AgentSessionProvider::Claude, + source: AgentSessionSource::Cli, + state: AgentSessionState::Idle, + pid: None, + transcript_path: None, + host: "devbox".to_string(), + workspace: AgentSessionWorkspace { + cwd: None, + project_name: None, + }, + activity: AgentSessionActivity { + started_at: None, + last_activity_at: None, + }, + focus_target: AgentSessionFocusTarget::None, + }], + error: Some("ssh not found".to_string()), + }; + + let json = serde_json::to_string(&result).unwrap(); + let decoded: AgentSessionHostResult = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, result); + } + + #[test] + fn windows_process_parser_uses_metadata_and_ignores_command_lines() { + let output = r#"[ + { + "ProcessId": 101, + "ParentProcessId": 1, + "CreationDate": "/Date(1783773458193)/", + "Name": "claude.exe", + "ExecutablePath": "C:\\Tools\\claude.exe", + "CommandLine": "claude.exe --api-key super-secret" + }, + { + "ProcessId": 202, + "ParentProcessId": 1, + "CreationDate": "2026-07-12T00:02:03Z", + "Name": "codex.exe", + "ExecutablePath": "C:\\Tools\\codex.exe" + } + ]"#; + + let records = WindowsProcessOutputParser::parse(output); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].provider, Some(AgentSessionProvider::Claude)); + assert_eq!(records[1].provider, Some(AgentSessionProvider::Codex)); + assert!(records[0].started_at.is_some()); + assert_eq!(records[0].executable, "claude.exe"); + assert!(!records[0].executable.contains("super-secret")); + } + + #[test] + fn windows_process_query_never_requests_raw_command_lines() { + let options = LocalAgentSessionScanner::process_options(Duration::from_secs(2)); + let script = options.extra_args.last().unwrap(); + + assert!(script.contains("ProcessId")); + assert!(script.contains("ExecutablePath")); + assert!(!script.contains("CommandLine")); + } + + #[test] + fn codex_discovery_only_builds_today_and_yesterday_directories() { + let now = Utc.with_ymd_and_hms(2026, 7, 12, 1, 2, 3).unwrap(); + let root = Path::new(r"C:\Users\me\.codex\sessions"); + + let directories = LocalAgentSessionScanner::codex_day_directories(root, now.date_naive()); + + assert_eq!( + directories, + vec![ + root.join("2026").join("07").join("12"), + root.join("2026").join("07").join("11"), + ] + ); + } + + #[test] + fn claude_metadata_parser_stops_after_session_and_cwd_are_known() { + struct FirstLineOnly { + bytes: &'static [u8], + offset: usize, + } + + impl Read for FirstLineOnly { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + assert!( + self.offset < self.bytes.len(), + "parser read past complete metadata" + ); + buffer[0] = self.bytes[self.offset]; + self.offset += 1; + Ok(1) + } + } + + let input = FirstLineOnly { + bytes: + b"{\"type\":\"user\",\"sessionId\":\"session-1\",\"cwd\":\"C:\\\\work\\\\proj\"}\n", + offset: 0, + }; + + let metadata = ClaudeTranscriptMetadataParser::parse(input).unwrap(); + + assert_eq!(metadata.session_id.as_deref(), Some("session-1")); + assert_eq!(metadata.cwd.as_deref(), Some(r"C:\work\proj")); + } + + #[test] + fn focus_rejects_remote_and_file_only_targets_explicitly() { + let remote = AgentSession { + id: "remote".to_string(), + provider: AgentSessionProvider::Codex, + source: AgentSessionSource::Cli, + state: AgentSessionState::Active, + pid: Some(42), + transcript_path: None, + host: "other-host".to_string(), + workspace: AgentSessionWorkspace { + cwd: None, + project_name: None, + }, + activity: AgentSessionActivity { + started_at: None, + last_activity_at: None, + }, + focus_target: AgentSessionFocusTarget::Process { pid: 42 }, + }; + let file_only = AgentSession { + host: "localhost".to_string(), + focus_target: AgentSessionFocusTarget::Transcript { + transcript_path: "session.jsonl".to_string(), + }, + ..remote.clone() + }; + + assert!(matches!( + focus_session(&remote), + SessionFocusResult::Unsupported { .. } + )); + assert!(matches!( + focus_session(&file_only), + SessionFocusResult::Unsupported { .. } + )); + } + + #[test] + fn ssh_fetch_uses_noninteractive_options_and_a_strict_timeout() { + let options = + RemoteSessionFetcher::ssh_options("user@devbox", Duration::from_secs(5)).unwrap(); + + assert_eq!(options.timeout, Duration::from_secs(5)); + assert!( + options + .extra_args + .windows(2) + .any(|pair| pair == ["-o", "BatchMode=yes"]) + ); + assert!( + options + .extra_args + .windows(2) + .any(|pair| pair == ["-o", "ConnectTimeout=3"]) + ); + assert!(options.extra_args.iter().any(|arg| arg == "user@devbox")); + } + + #[tokio::test] + async fn ssh_hosts_are_fetched_in_parallel_and_failures_are_isolated() { + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let results = tokio::time::timeout( + Duration::from_secs(1), + RemoteSessionFetcher::fetch_hosts_with( + &[ + "slow-a".to_string(), + "slow-b".to_string(), + "broken".to_string(), + ], + |host| { + let barrier = Arc::clone(&barrier); + async move { + if host == "broken" { + return AgentSessionHostResult::failed(host, "unreachable"); + } + barrier.wait().await; + AgentSessionHostResult::success(host, Vec::new()) + } + }, + ), + ) + .await + .expect("hosts were fetched sequentially"); + + assert_eq!(results.len(), 3); + assert_eq!( + results + .iter() + .filter(|result| result.error.is_some()) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| result.error.is_none()) + .count(), + 2 + ); + } + + #[tokio::test] + async fn disabled_discovery_returns_without_running_scanners() { + let result = AgentSessionDiscovery::default() + .scan(AgentSessionDiscoveryMode::Disabled) + .await; + + assert_eq!(result, AgentSessionDiscoveryResult::Disabled); + } +} From b592fd6ffb12cf0901602132f4afb54952830364 Mon Sep 17 00:00:00 2001 From: NessZerra Date: Sun, 12 Jul 2026 09:51:58 +0700 Subject: [PATCH 5/6] Reorganize settings groups Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/desktop-tauri/src/i18n/keys.ts | 5 +- apps/desktop-tauri/src/surfaces/Settings.tsx | 58 +++++++++++-------- .../src/surfaces/settings/tabs/DisplayTab.tsx | 19 ++++-- .../settings/tabs/GeneralTab.test.tsx | 9 ++- .../src/surfaces/settings/tabs/GeneralTab.tsx | 27 +++++---- apps/desktop-tauri/src/types/bridge.ts | 5 +- rust/src/locale.rs | 5 +- rust/src/locale/en-US.ftl | 5 +- rust/src/locale/es-MX.ftl | 5 +- rust/src/locale/ja-JP.ftl | 5 +- rust/src/locale/ko-KR.ftl | 5 +- rust/src/locale/zh-CN.ftl | 5 +- rust/src/locale/zh-TW.ftl | 5 +- 13 files changed, 98 insertions(+), 60 deletions(-) diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 448b3a1df0..b6e448b86f 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -6,8 +6,9 @@ export const ALL_LOCALE_KEYS = [ "TabGeneral", - "TabProviders", - "TabDisplay", + "TabNotifications", + "TabMenuBar", + "TabMenu", "TabApiKeys", "TabCookies", "TabAdvanced", diff --git a/apps/desktop-tauri/src/surfaces/Settings.tsx b/apps/desktop-tauri/src/surfaces/Settings.tsx index 34fda3f73e..a1b02221e4 100644 --- a/apps/desktop-tauri/src/surfaces/Settings.tsx +++ b/apps/desktop-tauri/src/surfaces/Settings.tsx @@ -52,20 +52,26 @@ const TabIcons: Record = { ), - providers: ( + notifications: ( - - - - + + ), - display: ( + menuBar: ( ), + menu: ( + + + + + + + ), advanced: ( @@ -83,13 +89,11 @@ const TabIcons: Record = { ), }; -// Tab order mirrors upstream PreferencesView (General, Providers, Display, -// Advanced, About). Per-provider credential management (API keys, cookies, -// token accounts) is handled inside the Providers tab. const TAB_META: { id: SettingsTab; labelKey: LocaleKey }[] = [ { id: "general", labelKey: "TabGeneral" }, - { id: "providers", labelKey: "TabProviders" }, - { id: "display", labelKey: "TabDisplay" }, + { id: "notifications", labelKey: "TabNotifications" }, + { id: "menuBar", labelKey: "TabMenuBar" }, + { id: "menu", labelKey: "TabMenu" }, { id: "advanced", labelKey: "TabAdvanced" }, { id: "about", labelKey: "TabAbout" }, ]; @@ -104,7 +108,7 @@ const SETTINGS_WINDOW_PROVIDERS_WIDTH = 600; async function applySettingsWindowSize(tab: SettingsTab) { const requestedWidth = - tab === "providers" + tab === "general" ? SETTINGS_WINDOW_PROVIDERS_WIDTH : SETTINGS_WINDOW_DEFAULT_WIDTH; const workArea = await getWorkAreaRect().catch(() => null); @@ -193,7 +197,7 @@ export default function Settings({ state, initialTab: propTab }: { state: Bootst return (
{/* custom title bar (decorations disabled for guaranteed dark theme) */}
@@ -244,20 +248,26 @@ export default function Settings({ state, initialTab: propTab }: { state: Bootst )} {/* tab panels */} -
+
{activeTab === "general" && ( - + <> + + + )} - {activeTab === "providers" && ( - + {activeTab === "notifications" && ( + + )} + {activeTab === "menuBar" && ( + )} - {activeTab === "display" && ( - + {activeTab === "menu" && ( + )} {activeTab === "advanced" && ( diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx index 782e4ab41e..36ef7376e2 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/DisplayTab.tsx @@ -9,7 +9,12 @@ function clampWindowScalePercent(value: number): number { return Math.min(250, Math.max(100, Number.isFinite(value) ? value : 100)); } -export default function DisplayTab({ settings, set, saving }: TabProps) { +export default function DisplayTab({ + mode = "menu", + settings, + set, + saving, +}: TabProps & { mode?: "menuBar" | "menu" }) { const { t } = useLocale(); const [windowScaleDraft, setWindowScaleDraft] = useState(() => clampWindowScalePercent(settings.windowScalePercent), @@ -28,7 +33,7 @@ export default function DisplayTab({ settings, set, saving }: TabProps) { return ( <> {/* ── Menu bar ─────────────────────────────────────────────── */} -
+ {mode === "menuBar" &&

{t("MenuBar")}

-
+
} {/* ── Menu content ─────────────────────────────────────────── */} -
+ {mode === "menu" &&

Menu Content

-
+
} - + {mode === "menu" && ( + + )} ); } diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx index 8a4cb5a1e0..ee9825a098 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.test.tsx @@ -103,7 +103,14 @@ describe("GeneralTab language picker", () => { it("updates the predictive pace warning preference", () => { const set = vi.fn(); - render(); + render( + , + ); fireEvent.click(screen.getByRole("checkbox", { name: "PredictivePaceWarnings" })); diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx index 8262695dac..2533058542 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/GeneralTab.tsx @@ -24,7 +24,12 @@ const REFRESH_CADENCE_OPTIONS: { value: string; label: string }[] = [ { value: "3600", label: "1 hour" }, ]; -export default function GeneralTab({ settings, set, saving }: TabProps) { +export default function GeneralTab({ + mode = "general", + settings, + set, + saving, +}: TabProps & { mode?: "general" | "notifications" }) { const { t } = useLocale(); const [playingSound, setPlayingSound] = useState(false); const [languageOptions, setLanguageOptions] = useState( @@ -45,7 +50,7 @@ export default function GeneralTab({ settings, set, saving }: TabProps) { return ( <> -
+ {mode === "general" &&

{t("SectionLanguage")}

@@ -60,9 +65,9 @@ export default function GeneralTab({ settings, set, saving }: TabProps) { />
-
+
} -
+ {mode === "general" &&

{t("StartupSettings")}

@@ -84,9 +89,9 @@ export default function GeneralTab({ settings, set, saving }: TabProps) { />
-
+
} -
+ {mode === "notifications" &&

{t("SectionNotifications")}

@@ -146,9 +151,9 @@ export default function GeneralTab({ settings, set, saving }: TabProps) { )}
-
+ } -
+ {mode === "notifications" &&

{t("SectionUsageThresholds")}

@@ -180,10 +185,10 @@ export default function GeneralTab({ settings, set, saving }: TabProps) { /> -
+
} {/* ── Automation ───────────────────────────────────────────── */} -
+ {mode === "general" &&

{t("SectionRefresh")}

-
+
} ); } diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 5e0eae2d9a..c0b7b90851 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -2,8 +2,9 @@ export type SurfaceMode = "hidden" | "trayPanel" | "popOut" | "settings"; export type VisibleSurfaceMode = Exclude; export type SettingsTabId = | "general" - | "providers" - | "display" + | "notifications" + | "menuBar" + | "menu" | "advanced" | "about"; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index ba3bb09d64..d0252b2bb6 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -158,8 +158,9 @@ locale_keys! { // Tab names (Preferences) TabGeneral, - TabProviders, - TabDisplay, + TabNotifications, + TabMenuBar, + TabMenu, TabApiKeys, TabCookies, TabAdvanced, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 60bc49f659..237ce874b7 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1,6 +1,7 @@ TabGeneral = General -TabProviders = Providers -TabDisplay = Display +TabNotifications = Notifications +TabMenuBar = Menu Bar +TabMenu = Menu TabApiKeys = API Keys TabCookies = Cookies TabAdvanced = Advanced diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 0613daf453..f19f2d770a 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1,6 +1,7 @@ TabGeneral = General -TabProviders = Proveedores -TabDisplay = Pantalla +TabNotifications = Notifications +TabMenuBar = Menu Bar +TabMenu = Menu TabApiKeys = Claves API TabCookies = Cookies TabAdvanced = Avanzado diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index acb4e8986b..b4314ddcfc 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1,6 +1,7 @@ TabGeneral = 一般 -TabProviders = プロバイダー -TabDisplay = 表示 +TabNotifications = Notifications +TabMenuBar = Menu Bar +TabMenu = Menu TabApiKeys = API Keys TabCookies = Cookies TabAdvanced = 詳細 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index fa87f6436e..5f456ae1ec 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1,6 +1,7 @@ TabGeneral = 일반 -TabProviders = 제공업체 -TabDisplay = 디스플레이 +TabNotifications = Notifications +TabMenuBar = Menu Bar +TabMenu = Menu TabApiKeys = API 키 TabCookies = 쿠키 TabAdvanced = 고급 diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 331a9f8456..5740f151da 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1,6 +1,7 @@ TabGeneral = 通用 -TabProviders = 服务商 -TabDisplay = 显示 +TabNotifications = Notifications +TabMenuBar = Menu Bar +TabMenu = Menu TabApiKeys = API 密钥 TabCookies = Cookie TabAdvanced = 高级 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index fe3b2c0097..8ae2962a4e 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1,6 +1,7 @@ TabGeneral = 一般 -TabProviders = 提供者 -TabDisplay = 顯示 +TabNotifications = Notifications +TabMenuBar = Menu Bar +TabMenu = Menu TabApiKeys = API 金鑰 TabCookies = Cookie TabAdvanced = 高階 From 681f1555912cbacc9583876a53e6ba3061d717c5 Mon Sep 17 00:00:00 2001 From: NessZerra Date: Sun, 12 Jul 2026 10:12:36 +0700 Subject: [PATCH 6/6] Complete session and refresh parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src-tauri/src/commands/providers.rs | 10 ++++- apps/desktop-tauri/src-tauri/src/events.rs | 10 ++++- .../desktop-tauri/src/components/MenuCard.tsx | 5 ++- .../src/hooks/useProviders.test.tsx | 30 ++++++++++++- apps/desktop-tauri/src/hooks/useProviders.ts | 30 +++++++++---- .../src/surfaces/PopOutPanel.tsx | 2 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 + apps/desktop-tauri/src/types/bridge.ts | 4 ++ rust/src/agent_sessions.rs | 42 ++++++++++++++++++- rust/src/agent_sessions/parsers.rs | 20 +++++++++ rust/src/agent_sessions/tests.rs | 33 +++++++++++++++ 11 files changed, 171 insertions(+), 17 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 241e4fbaff..7ffd9f7275 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -169,9 +169,15 @@ async fn do_refresh_providers_with_policy( return Ok(()); } - events::emit_refresh_started(app); - let inputs = ProviderRefreshInputs::load(); + events::emit_refresh_started( + app, + inputs + .enabled_ids + .iter() + .map(|id| id.cli_name().to_string()) + .collect(), + ); let enabled_count = inputs.enabled_ids.len(); let handles = spawn_provider_refreshes(app, &inputs); diff --git a/apps/desktop-tauri/src-tauri/src/events.rs b/apps/desktop-tauri/src-tauri/src/events.rs index 13bde5f6b6..83660a4005 100644 --- a/apps/desktop-tauri/src-tauri/src/events.rs +++ b/apps/desktop-tauri/src-tauri/src/events.rs @@ -39,6 +39,12 @@ pub struct RefreshCompletePayload { pub error_count: usize, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RefreshStartedPayload { + pub provider_ids: Vec, +} + // ── Emit helpers ───────────────────────────────────────────────────── pub fn emit_surface_mode_changed( @@ -66,8 +72,8 @@ pub fn emit_provider_updated(app: &AppHandle, snapshot: &ProviderUsageSnapshot) let _ = app.emit(PROVIDER_UPDATED, snapshot); } -pub fn emit_refresh_started(app: &AppHandle) { - let _ = app.emit(REFRESH_STARTED, ()); +pub fn emit_refresh_started(app: &AppHandle, provider_ids: Vec) { + let _ = app.emit(REFRESH_STARTED, RefreshStartedPayload { provider_ids }); } pub fn emit_refresh_complete(app: &AppHandle, provider_count: usize, error_count: usize) { diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index a9d7ef1745..4cd7a5aa39 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -52,6 +52,7 @@ interface MenuCardProps { showResetWhenExhausted?: boolean; showAsUsed?: boolean; compactMetrics?: boolean; + isRefreshing?: boolean; onLayoutChange?: () => void; } @@ -419,6 +420,7 @@ export default function MenuCard({ showResetWhenExhausted = false, showAsUsed = false, compactMetrics = false, + isRefreshing = false, onLayoutChange, }: MenuCardProps) { const { t } = useLocale(); @@ -520,13 +522,14 @@ export default function MenuCard({ const cardClassName = [ "menu-card", provider.error ? "menu-card--error" : null, + isRefreshing ? "menu-card--refreshing" : null, hasDetails ? "menu-card--with-details" : "menu-card--header-only", ] .filter(Boolean) .join(" "); return ( -
+
diff --git a/apps/desktop-tauri/src/hooks/useProviders.test.tsx b/apps/desktop-tauri/src/hooks/useProviders.test.tsx index 81c1f9f336..058e7f0120 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.test.tsx +++ b/apps/desktop-tauri/src/hooks/useProviders.test.tsx @@ -369,12 +369,13 @@ describe("useProviders", () => { vi.useFakeTimers(); try { act(() => { - emitProviderEvent("refresh-started", {}); + emitProviderEvent("refresh-started", { providerIds: ["codex"] }); emitProviderEvent("provider-updated", provider("codex", 10)); emitProviderEvent("refresh-complete", { providerCount: 1, errorCount: 0, }); + }); expect(result.current.providers.map((snapshot) => snapshot.providerId)).toEqual([ @@ -389,4 +390,31 @@ describe("useProviders", () => { vi.useRealTimers(); } }); + + it("clears each provider from refresh state as it completes", async () => { + const { result } = renderHook(() => + useProviders({ refreshOnMount: false }), + ); + await waitFor(() => expect(result.current.hasLoadedCache).toBe(true)); + + act(() => + emitProviderEvent("refresh-started", { + providerIds: ["codex", "claude"], + }), + ); + expect([...result.current.refreshingProviderIds]).toEqual(["codex", "claude"]); + + act(() => emitProviderEvent("provider-updated", provider("codex", 10))); + expect([...result.current.refreshingProviderIds]).toEqual(["claude"]); + expect(result.current.isRefreshing).toBe(true); + + act(() => + emitProviderEvent("refresh-complete", { + providerCount: 2, + errorCount: 0, + }), + ); + expect(result.current.refreshingProviderIds.size).toBe(0); + expect(result.current.isRefreshing).toBe(false); + }); }); diff --git a/apps/desktop-tauri/src/hooks/useProviders.ts b/apps/desktop-tauri/src/hooks/useProviders.ts index c4a8c06d14..36d57e965a 100644 --- a/apps/desktop-tauri/src/hooks/useProviders.ts +++ b/apps/desktop-tauri/src/hooks/useProviders.ts @@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event"; import type { ProviderUsageSnapshot, RefreshCompletePayload, + RefreshStartedPayload, } from "../types/bridge"; import { getCachedProviders, @@ -35,6 +36,7 @@ export interface UseProvidersResult { providers: ProviderUsageSnapshot[]; /** True while a refresh cycle is in progress. */ isRefreshing: boolean; + refreshingProviderIds: ReadonlySet; /** Trigger a manual refresh. No-op if already refreshing. */ refresh: () => void; /** Summary from the last completed refresh cycle, if any. */ @@ -57,7 +59,9 @@ export interface UseProvidersResult { */ export function useProviders(options: UseProvidersOptions = {}): UseProvidersResult { const [providers, setProviders] = useState([]); - const [isRefreshing, setIsRefreshing] = useState(false); + const [refreshingProviderIds, setRefreshingProviderIds] = useState>( + new Set(), + ); const [lastRefresh, setLastRefresh] = useState( null, ); @@ -106,10 +110,9 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes const refresh = useCallback(() => { if (refreshingRef.current) return; refreshingRef.current = true; - setIsRefreshing(true); refreshProviders().catch(() => { refreshingRef.current = false; - setIsRefreshing(false); + setRefreshingProviderIds(new Set()); }); }, []); @@ -138,7 +141,15 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes const unlistenUpdated = listen( "provider-updated", (event) => { - if (!cancelled) queueSnapshot(event.payload); + if (!cancelled) { + queueSnapshot(event.payload); + setRefreshingProviderIds( + (current) => + new Set( + [...current].filter((id) => id !== event.payload.providerId), + ), + ); + } }, ); @@ -164,10 +175,10 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes }); }); - const unlistenStarted = listen("refresh-started", () => { + const unlistenStarted = listen("refresh-started", (event) => { if (!cancelled) { refreshingRef.current = true; - setIsRefreshing(true); + setRefreshingProviderIds(new Set(event.payload.providerIds)); } }); @@ -177,7 +188,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes if (!cancelled) { if (!settingsReloadingRef.current) flushPendingSnapshots(); refreshingRef.current = false; - setIsRefreshing(false); + setRefreshingProviderIds(new Set()); setLastRefresh(event.payload); } }, @@ -192,7 +203,7 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes refreshPromise.catch(() => { if (!cancelled) { refreshingRef.current = false; - setIsRefreshing(false); + setRefreshingProviderIds(new Set()); } }); }; @@ -277,7 +288,8 @@ export function useProviders(options: UseProvidersOptions = {}): UseProvidersRes return { providers, - isRefreshing, + isRefreshing: refreshingProviderIds.size > 0, + refreshingProviderIds, refresh, lastRefresh, hasCachedData: providers.length > 0, diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx index a3984446bb..9f46d42798 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.tsx @@ -31,6 +31,7 @@ export default function PopOutPanel({ const { providers, isRefreshing, + refreshingProviderIds, refresh, hasCachedData, } = useProviders(); @@ -251,6 +252,7 @@ export default function PopOutPanel({ > Result, String> { + let options = CommandOptions { + timeout: Duration::from_secs(5), + initial_delay: Duration::ZERO, + extra_args: vec!["status".to_string(), "--json".to_string()], + ..CommandOptions::default() + }; + match CommandRunner::new().run_async("tailscale", None, &options).await { + Err(CommandError::BinaryNotFound(_)) => Ok(Vec::new()), + Err(_) => Err( + "Unable to query Tailscale peers; manual SSH hosts are still available.".to_string(), + ), + Ok(result) if result.exit_code == Some(0) && !result.timed_out => { + TailscaleStatusParser::hosts(&result.text).map_err(|_| { + "Tailscale returned an invalid status response; manual SSH hosts are still available." + .to_string() + }) + } + Ok(_) => Err( + "Tailscale status failed; manual SSH hosts are still available.".to_string(), + ), + } + } + async fn fetch_host(host: String, timeout: Duration) -> AgentSessionHostResult { let options = match Self::ssh_options(&host, timeout) { Ok(options) => options, @@ -858,6 +882,10 @@ impl RemoteSessionFetcher { sanitized } + pub fn merge_hosts(manual: &[String], automatic: &[String]) -> Vec { + Self::sanitized_hosts(&manual.iter().chain(automatic).cloned().collect::>()) + } + pub fn validate_host(host: &str) -> Result { let host = host.trim(); if host.is_empty() { @@ -910,7 +938,17 @@ impl AgentSessionDiscovery { let AgentSessionDiscoveryMode::Enabled { ssh_hosts } = mode else { return AgentSessionDiscoveryResult::Disabled; }; - let (local, remote) = tokio::join!(self.local.scan(), self.remote.fetch(&ssh_hosts)); + let (local, automatic) = + tokio::join!(self.local.scan(), RemoteSessionFetcher::tailscale_hosts()); + let (automatic_hosts, tailscale_error) = match automatic { + Ok(hosts) => (hosts, None), + Err(error) => (Vec::new(), Some(error)), + }; + let merged_hosts = RemoteSessionFetcher::merge_hosts(&ssh_hosts, &automatic_hosts); + let mut remote = self.remote.fetch(&merged_hosts).await; + if let Some(error) = tailscale_error { + remote.push(AgentSessionHostResult::failed("tailscale", error)); + } let mut hosts = Vec::with_capacity(remote.len() + 1); hosts.push(local); hosts.extend(remote); diff --git a/rust/src/agent_sessions/parsers.rs b/rust/src/agent_sessions/parsers.rs index b297b889a2..b425486326 100644 --- a/rust/src/agent_sessions/parsers.rs +++ b/rust/src/agent_sessions/parsers.rs @@ -1,5 +1,25 @@ use super::*; +impl TailscaleStatusParser { + pub fn hosts(json: &str) -> Result, String> { + let status: serde_json::Value = + serde_json::from_str(json).map_err(|_| "invalid Tailscale status JSON".to_string())?; + let mut hosts = status + .get("Peer") + .and_then(serde_json::Value::as_object) + .into_iter() + .flat_map(|peers| peers.values()) + .filter(|peer| peer.get("Online").and_then(serde_json::Value::as_bool) == Some(true)) + .filter_map(|peer| peer.get("DNSName").and_then(serde_json::Value::as_str)) + .map(|host| host.trim().trim_end_matches('.').to_string()) + .filter(|host| !host.is_empty()) + .filter(|host| RemoteSessionFetcher::validate_host(host).is_ok()) + .collect::>(); + hosts.sort_by_key(|host| host.to_ascii_lowercase()); + Ok(RemoteSessionFetcher::sanitized_hosts(&hosts)) + } +} + impl AgentPSOutputParser { pub fn parse(output: &str) -> Vec { let mut seen_pids = HashSet::new(); diff --git a/rust/src/agent_sessions/tests.rs b/rust/src/agent_sessions/tests.rs index 6418efd011..36fdb93723 100644 --- a/rust/src/agent_sessions/tests.rs +++ b/rust/src/agent_sessions/tests.rs @@ -101,6 +101,39 @@ bad line assert_eq!(hosts, vec!["good".to_string()]); } + #[test] + fn tailscale_parser_returns_online_peer_dns_names() { + let json = r#"{ + "Self": {"DNSName": "this-pc.tailnet.ts.net."}, + "Peer": { + "one": {"DNSName": "devbox.tailnet.ts.net.", "Online": true}, + "two": {"DNSName": "offline.tailnet.ts.net.", "Online": false}, + "three": {"DNSName": "", "Online": true} + } + }"#; + + assert_eq!( + TailscaleStatusParser::hosts(json).unwrap(), + vec!["devbox.tailnet.ts.net"] + ); + } + + #[test] + fn tailscale_parser_rejects_malformed_json() { + assert!(TailscaleStatusParser::hosts("{").is_err()); + } + + #[test] + fn automatic_and_manual_hosts_are_validated_and_deduplicated() { + assert_eq!( + RemoteSessionFetcher::merge_hosts( + &["manual".into(), "DEVBOX.tailnet.ts.net".into()], + &["devbox.tailnet.ts.net".into(), "-unsafe".into()], + ), + vec!["manual", "DEVBOX.tailnet.ts.net"] + ); + } + #[test] fn codex_rollout_parser_reads_first_line_metadata() { let metadata = CodexRolloutFirstLineParser::parse(