From 8aec8dcccf4c19afe95a09dda9071c312643014a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 25 Nov 2025 18:12:45 +1100 Subject: [PATCH] optional reading of external sessions --- crates/goose/src/config/base.rs | 1 + crates/goose/src/session/claude_code.rs | 264 +++++++++++++++ crates/goose/src/session/codex.rs | 316 ++++++++++++++++++ crates/goose/src/session/external_sessions.rs | 208 ++++++++++++ crates/goose/src/session/mod.rs | 3 + crates/goose/src/session/session_manager.rs | 28 +- .../sessions/SessionSharingSection.tsx | 30 ++ 7 files changed, 844 insertions(+), 6 deletions(-) create mode 100644 crates/goose/src/session/claude_code.rs create mode 100644 crates/goose/src/session/codex.rs create mode 100644 crates/goose/src/session/external_sessions.rs diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index ddae0fd20f..bb949090ca 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -808,6 +808,7 @@ config_value!(GOOSE_MODE, GooseMode); config_value!(GOOSE_PROVIDER, String); config_value!(GOOSE_MODEL, String); config_value!(GOOSE_MAX_ACTIVE_AGENTS, usize); +config_value!(GOOSE_ENABLE_EXTERNAL_SESSIONS, bool); /// Load init-config.yaml from workspace root if it exists. /// This function is shared between the config recovery and the init_config endpoint. diff --git a/crates/goose/src/session/claude_code.rs b/crates/goose/src/session/claude_code.rs new file mode 100644 index 0000000000..d501bfe880 --- /dev/null +++ b/crates/goose/src/session/claude_code.rs @@ -0,0 +1,264 @@ +use crate::conversation::message::{Message, MessageContent}; +use crate::conversation::Conversation; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use rmcp::model::Role; +use serde::Deserialize; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +struct ClaudeJsonLine { + #[serde(rename = "type")] + entry_type: Option, + #[serde(rename = "sessionId")] + session_id: Option, + cwd: Option, + timestamp: Option, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeMessageWrapper { + role: Option, + content: serde_json::Value, +} + +fn get_home_dir() -> Option { + std::env::var("HOME") + .ok() + .map(PathBuf::from) + .or_else(dirs::home_dir) +} + +pub fn list_claude_code_sessions() -> Result)>> { + let home = get_home_dir().ok_or_else(|| anyhow::anyhow!("No home dir"))?; + let projects_dir = home.join(".claude").join("projects"); + + tracing::debug!("Checking for Claude Code sessions in: {:?}", projects_dir); + + if !projects_dir.exists() { + tracing::debug!("Claude projects directory does not exist"); + return Ok(Vec::new()); + } + + let mut sessions = Vec::new(); + + for entry in std::fs::read_dir(projects_dir)? { + let entry = entry?; + let project_path = entry.path(); + + if !project_path.is_dir() { + continue; + } + + for file_entry in std::fs::read_dir(&project_path)? { + let file_entry = file_entry?; + let file_path = file_entry.path(); + + if !file_path.is_file() { + continue; + } + + let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if file_name.starts_with("agent-") || !file_name.ends_with(".jsonl") { + continue; + } + + if let Ok((session_id, working_dir, updated_at)) = parse_session_metadata(&file_path) { + tracing::debug!( + "Found Claude session: {} updated at {}", + session_id, + updated_at + ); + sessions.push((session_id, working_dir, updated_at)); + } + } + } + + tracing::debug!("Total Claude sessions found: {}", sessions.len()); + + sessions.sort_by(|a, b| b.2.cmp(&a.2)); + sessions.truncate(10); + + Ok(sessions) +} + +fn parse_session_metadata(file_path: &PathBuf) -> Result<(String, PathBuf, DateTime)> { + let file = File::open(file_path)?; + let reader = BufReader::new(file); + + let mut session_id = None; + let mut working_dir = None; + let mut latest_timestamp = None; + + for line in reader.lines() { + let line = line?; + if let Ok(entry) = serde_json::from_str::(&line) { + if session_id.is_none() { + session_id = entry.session_id; + } + if working_dir.is_none() { + working_dir = entry.cwd; + } + if let Some(ts) = entry.timestamp { + if let Ok(dt) = DateTime::parse_from_rfc3339(&ts) { + latest_timestamp = Some(dt.with_timezone(&Utc)); + } + } + } + } + + let session_id = session_id.ok_or_else(|| anyhow::anyhow!("No session ID found"))?; + let working_dir = working_dir.ok_or_else(|| anyhow::anyhow!("No working dir found"))?; + let updated_at = latest_timestamp.unwrap_or_else(Utc::now); + + Ok((session_id, PathBuf::from(working_dir), updated_at)) +} + +pub fn load_claude_code_session(session_id: &str) -> Result { + let home = get_home_dir().ok_or_else(|| anyhow::anyhow!("No home dir"))?; + let projects_dir = home.join(".claude").join("projects"); + + for entry in std::fs::read_dir(projects_dir)? { + let entry = entry?; + let project_path = entry.path(); + + if !project_path.is_dir() { + continue; + } + + for file_entry in std::fs::read_dir(&project_path)? { + let file_entry = file_entry?; + let file_path = file_entry.path(); + + if !file_path.is_file() { + continue; + } + + let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if file_name.contains(session_id) && file_name.ends_with(".jsonl") { + return parse_conversation(&file_path); + } + } + } + + Err(anyhow::anyhow!("Session not found")) +} + +fn parse_conversation(file_path: &PathBuf) -> Result { + let file = File::open(file_path)?; + let reader = BufReader::new(file); + + let mut messages = Vec::new(); + + for line in reader.lines() { + let line = line?; + if let Ok(entry) = serde_json::from_str::(&line) { + if entry.entry_type.as_deref() == Some("user") { + if let Some(msg) = entry.message { + if let Some(role_str) = msg.role { + if role_str == "user" { + let content = parse_message_content(&msg.content); + if !content.is_empty() { + let timestamp = entry + .timestamp + .and_then(|ts| DateTime::parse_from_rfc3339(&ts).ok()) + .map(|dt| dt.with_timezone(&Utc).timestamp_millis()) + .unwrap_or_else(|| Utc::now().timestamp_millis()); + + messages.push(Message::new(Role::User, timestamp, content)); + } + } + } + } + } else if entry.entry_type.as_deref() == Some("assistant") { + if let Some(msg) = entry.message { + if let Some(role_str) = msg.role { + if role_str == "assistant" { + let content = parse_message_content(&msg.content); + if !content.is_empty() { + let timestamp = entry + .timestamp + .and_then(|ts| DateTime::parse_from_rfc3339(&ts).ok()) + .map(|dt| dt.with_timezone(&Utc).timestamp_millis()) + .unwrap_or_else(|| Utc::now().timestamp_millis()); + + messages.push(Message::new(Role::Assistant, timestamp, content)); + } + } + } + } + } + } + } + + Ok(Conversation::new_unvalidated(messages)) +} + +fn parse_message_content(content_value: &serde_json::Value) -> Vec { + let mut result = Vec::new(); + + if let Some(text) = content_value.as_str() { + result.push(MessageContent::text(text.to_string())); + return result; + } + + if let Some(content_array) = content_value.as_array() { + for item in content_array { + if let Some(obj) = item.as_object() { + if let Some(content_type) = obj.get("type").and_then(|v| v.as_str()) { + match content_type { + "text" => { + if let Some(text) = obj.get("text").and_then(|v| v.as_str()) { + result.push(MessageContent::text(text.to_string())); + } + } + "tool_result" => { + if let Some(content) = obj.get("content").and_then(|v| v.as_str()) { + result.push(MessageContent::text(format!( + "[Tool Result]\n{}", + content + ))); + } + } + _ => {} + } + } + } + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + const SAMPLE_CLAUDE_JSONL: &str = r#"{"type":"file-history-snapshot","messageId":"4cd88dd4-8312-458b-a70d-403aa5f6e8c6","snapshot":{"messageId":"4cd88dd4-8312-458b-a70d-403aa5f6e8c6","trackedFileBackups":{},"timestamp":"2025-11-19T21:24:34.677Z"},"isSnapshotUpdate":false} +{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/test/project","sessionId":"f601a187-ac89-4568-9328-c564f9f0d455","version":"2.0.33","gitBranch":"main","type":"user","message":{"role":"user","content":"what is the change in this branch?"},"uuid":"4cd88dd4-8312-458b-a70d-403aa5f6e8c6","timestamp":"2025-11-19T21:24:34.672Z","thinkingMetadata":{"level":"high","disabled":false,"triggers":[]}} +{"parentUuid":"4cd88dd4-8312-458b-a70d-403aa5f6e8c6","isSidechain":false,"userType":"external","cwd":"/Users/test/project","sessionId":"f601a187-ac89-4568-9328-c564f9f0d455","version":"2.0.33","gitBranch":"main","type":"assistant","message":{"model":"claude-sonnet-4","id":"msg_012DrstbP1gwSu8uLGsz2piZ","type":"message","role":"assistant","content":[{"type":"text","text":"I'll check the git log for you"}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":15}},"requestId":"req_011CVHxEHvENmh3uMxgA6AMZ","uuid":"03a66873-5fc1-43ac-8652-25fff6c55e00","timestamp":"2025-11-19T21:24:37.926Z"} +"#; + + #[test] + fn test_parse_claude_code_session() { + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(SAMPLE_CLAUDE_JSONL.as_bytes()).unwrap(); + + let (session_id, working_dir, _timestamp) = + parse_session_metadata(&temp_file.path().to_path_buf()).unwrap(); + assert_eq!(session_id, "f601a187-ac89-4568-9328-c564f9f0d455"); + assert_eq!(working_dir, PathBuf::from("/Users/test/project")); + + let conversation = parse_conversation(&temp_file.path().to_path_buf()).unwrap(); + assert_eq!(conversation.messages().len(), 2); + assert_eq!(conversation.messages()[0].role, Role::User); + assert_eq!(conversation.messages()[1].role, Role::Assistant); + } +} diff --git a/crates/goose/src/session/codex.rs b/crates/goose/src/session/codex.rs new file mode 100644 index 0000000000..f1c4eb12f6 --- /dev/null +++ b/crates/goose/src/session/codex.rs @@ -0,0 +1,316 @@ +use crate::conversation::message::{Message, MessageContent}; +use crate::conversation::Conversation; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use rmcp::model::Role; +use serde::Deserialize; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +struct CodexJsonLine { + timestamp: Option, + #[serde(rename = "type")] + entry_type: String, + payload: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +struct SessionMeta { + id: String, + cwd: String, + timestamp: Option, +} + +#[derive(Debug, Deserialize)] +struct ResponseItem { + #[serde(rename = "type")] + item_type: String, + role: Option, + content: Option, +} + +fn get_home_dir() -> Option { + std::env::var("HOME") + .ok() + .map(PathBuf::from) + .or_else(dirs::home_dir) +} + +pub fn list_codex_sessions() -> Result)>> { + let home = get_home_dir().ok_or_else(|| anyhow::anyhow!("No home dir"))?; + let sessions_dir = home.join(".codex").join("sessions"); + + tracing::debug!("Checking for Codex sessions in: {:?}", sessions_dir); + + if !sessions_dir.exists() { + tracing::debug!("Codex sessions directory does not exist"); + return Ok(Vec::new()); + } + + let mut sessions = Vec::new(); + + for year_entry in std::fs::read_dir(&sessions_dir)? { + let year_entry = year_entry?; + let year_path = year_entry.path(); + + if !year_path.is_dir() { + continue; + } + + for month_entry in std::fs::read_dir(&year_path)? { + let month_entry = month_entry?; + let month_path = month_entry.path(); + + if !month_path.is_dir() { + continue; + } + + for day_entry in std::fs::read_dir(&month_path)? { + let day_entry = day_entry?; + let day_path = day_entry.path(); + + if !day_path.is_dir() { + continue; + } + + for file_entry in std::fs::read_dir(&day_path)? { + let file_entry = file_entry?; + let file_path = file_entry.path(); + + if !file_path.is_file() { + continue; + } + + let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if !file_name.starts_with("rollout-") || !file_name.ends_with(".jsonl") { + continue; + } + + if let Ok((session_id, working_dir, updated_at)) = + parse_session_metadata(&file_path) + { + tracing::debug!( + "Found Codex session: {} updated at {}", + session_id, + updated_at + ); + sessions.push((session_id, working_dir, updated_at)); + } + } + } + } + } + + tracing::debug!("Total Codex sessions found: {}", sessions.len()); + + sessions.sort_by(|a, b| b.2.cmp(&a.2)); + sessions.truncate(10); + + Ok(sessions) +} + +fn parse_session_metadata(file_path: &PathBuf) -> Result<(String, PathBuf, DateTime)> { + let file = File::open(file_path)?; + let reader = BufReader::new(file); + + let mut session_id = None; + let mut working_dir = None; + let mut latest_timestamp = None; + + for line in reader.lines() { + let line = line?; + if let Ok(entry) = serde_json::from_str::(&line) { + // Parse session metadata from first line + if entry.entry_type == "session_meta" { + if let Ok(meta) = serde_json::from_value::(entry.payload.clone()) { + session_id = Some(meta.id); + working_dir = Some(meta.cwd); + if let Some(ts) = meta.timestamp { + if let Ok(dt) = DateTime::parse_from_rfc3339(&ts) { + latest_timestamp = Some(dt.with_timezone(&Utc)); + } + } + break; + } + } + + // Update latest timestamp from any entry + if let Some(ts) = entry.timestamp { + if let Ok(dt) = DateTime::parse_from_rfc3339(&ts) { + latest_timestamp = Some(dt.with_timezone(&Utc)); + } + } + } + } + + let session_id = session_id.ok_or_else(|| anyhow::anyhow!("No session ID found"))?; + let working_dir = working_dir.ok_or_else(|| anyhow::anyhow!("No working dir found"))?; + let updated_at = latest_timestamp.unwrap_or_else(Utc::now); + + Ok((session_id, PathBuf::from(working_dir), updated_at)) +} + +pub fn load_codex_session(session_id: &str) -> Result { + let home = get_home_dir().ok_or_else(|| anyhow::anyhow!("No home dir"))?; + let sessions_dir = home.join(".codex").join("sessions"); + + // Walk through all directories to find the session file + for year_entry in std::fs::read_dir(&sessions_dir)? { + let year_entry = year_entry?; + let year_path = year_entry.path(); + + if !year_path.is_dir() { + continue; + } + + for month_entry in std::fs::read_dir(&year_path)? { + let month_entry = month_entry?; + let month_path = month_entry.path(); + + if !month_path.is_dir() { + continue; + } + + for day_entry in std::fs::read_dir(&month_path)? { + let day_entry = day_entry?; + let day_path = day_entry.path(); + + if !day_path.is_dir() { + continue; + } + + for file_entry in std::fs::read_dir(&day_path)? { + let file_entry = file_entry?; + let file_path = file_entry.path(); + + if !file_path.is_file() { + continue; + } + + let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + if file_name.contains(session_id) && file_name.ends_with(".jsonl") { + return parse_conversation(&file_path); + } + } + } + } + } + + Err(anyhow::anyhow!("Session not found")) +} + +fn parse_conversation(file_path: &PathBuf) -> Result { + let file = File::open(file_path)?; + let reader = BufReader::new(file); + + let mut messages = Vec::new(); + + for line in reader.lines() { + let line = line?; + if let Ok(entry) = serde_json::from_str::(&line) { + // Only process response_item entries + if entry.entry_type == "response_item" { + if let Ok(item) = serde_json::from_value::(entry.payload.clone()) { + // Only process message types + if item.item_type == "message" { + if let Some(role_str) = item.role { + let role = match role_str.as_str() { + "user" => Role::User, + "assistant" => Role::Assistant, + _ => continue, + }; + + if let Some(content_value) = item.content { + let content = parse_message_content(&content_value); + + // Skip environment context messages + if !content.is_empty() { + let text = content + .iter() + .filter_map(|c| match c { + MessageContent::Text(text_content) => { + Some(text_content.text.as_str()) + } + _ => None, + }) + .collect::>() + .join(""); + + if !text.starts_with("") { + let timestamp = entry + .timestamp + .and_then(|ts| DateTime::parse_from_rfc3339(&ts).ok()) + .map(|dt| dt.with_timezone(&Utc).timestamp_millis()) + .unwrap_or_else(|| Utc::now().timestamp_millis()); + + messages.push(Message::new(role, timestamp, content)); + } + } + } + } + } + } + } + } + } + + Ok(Conversation::new_unvalidated(messages)) +} + +fn parse_message_content(content_value: &serde_json::Value) -> Vec { + let mut result = Vec::new(); + + // Handle string content + if let Some(text) = content_value.as_str() { + result.push(MessageContent::text(text.to_string())); + return result; + } + + // Handle array content + if let Some(content_array) = content_value.as_array() { + for item in content_array { + if let Some(obj) = item.as_object() { + if let Some("input_text" | "output_text") = obj.get("type").and_then(|v| v.as_str()) + { + if let Some(text) = obj.get("text").and_then(|v| v.as_str()) { + result.push(MessageContent::text(text.to_string())); + } + } + } + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + const SAMPLE_CODEX_JSONL: &str = r#"{"timestamp":"2025-11-20T22:56:16.194Z","type":"session_meta","payload":{"id":"019aa37b-9321-7fa3-9b75-eaf925a29a3c","timestamp":"2025-11-20T22:56:16.161Z","cwd":"/Users/test/project","originator":"codex_cli_rs","cli_version":"0.55.0"}} +{"timestamp":"2025-11-20T22:56:19.969Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"what does this project do"}]}} +{"timestamp":"2025-11-20T22:56:25.665Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"This looks like a Rust project"}]}} +"#; + + #[test] + fn test_parse_codex_session() { + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(SAMPLE_CODEX_JSONL.as_bytes()).unwrap(); + + let (session_id, working_dir, _timestamp) = + parse_session_metadata(&temp_file.path().to_path_buf()).unwrap(); + assert_eq!(session_id, "019aa37b-9321-7fa3-9b75-eaf925a29a3c"); + assert_eq!(working_dir, PathBuf::from("/Users/test/project")); + + let conversation = parse_conversation(&temp_file.path().to_path_buf()).unwrap(); + assert_eq!(conversation.messages().len(), 2); + assert_eq!(conversation.messages()[0].role, Role::User); + assert_eq!(conversation.messages()[1].role, Role::Assistant); + } +} diff --git a/crates/goose/src/session/external_sessions.rs b/crates/goose/src/session/external_sessions.rs new file mode 100644 index 0000000000..a6956187a3 --- /dev/null +++ b/crates/goose/src/session/external_sessions.rs @@ -0,0 +1,208 @@ +use crate::config::Config; +use crate::session::extension_data::ExtensionData; +use crate::session::session_manager::{Session, SessionType}; + +fn is_enabled() -> bool { + #[cfg(test)] + return false; + + #[cfg(not(test))] + Config::global() + .get_goose_enable_external_sessions() + .ok() + .unwrap_or(false) +} + +pub fn get_external_session(id: &str, include_messages: bool) -> Option { + if !is_enabled() { + return None; + } + + if let Some(session) = get_claude_code_session(id, include_messages) { + return Some(session); + } + + get_codex_session(id, include_messages) +} + +fn get_claude_code_session(id: &str, include_messages: bool) -> Option { + let sessions = crate::session::claude_code::list_claude_code_sessions().ok()?; + + for (session_id, working_dir, updated_at) in sessions { + if session_id == id { + let conversation = if include_messages { + crate::session::claude_code::load_claude_code_session(id).ok() + } else { + None + }; + + let message_count = conversation + .as_ref() + .map(|c| c.messages().len()) + .unwrap_or(0); + + return Some(Session { + id: session_id.clone(), + working_dir, + name: format!( + "Claude Code Session {}", + session_id.chars().take(8).collect::() + ), + user_set_name: false, + session_type: SessionType::User, + created_at: updated_at, + updated_at, + extension_data: ExtensionData::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation, + message_count, + provider_name: None, + model_config: None, + }); + } + } + None +} + +fn get_codex_session(id: &str, include_messages: bool) -> Option { + let sessions = crate::session::codex::list_codex_sessions().ok()?; + + for (session_id, working_dir, updated_at) in sessions { + if session_id == id { + let conversation = if include_messages { + crate::session::codex::load_codex_session(id).ok() + } else { + None + }; + + let message_count = conversation + .as_ref() + .map(|c| c.messages().len()) + .unwrap_or(0); + + return Some(Session { + id: session_id.clone(), + working_dir, + name: format!( + "Codex Session {}", + session_id.chars().take(8).collect::() + ), + user_set_name: false, + session_type: SessionType::User, + created_at: updated_at, + updated_at, + extension_data: ExtensionData::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation, + message_count, + provider_name: None, + model_config: None, + }); + } + } + None +} + +pub fn get_external_sessions_for_list() -> Vec { + let enabled = is_enabled(); + tracing::debug!("External sessions enabled: {}", enabled); + + if !enabled { + return Vec::new(); + } + + let mut sessions = Vec::new(); + + match crate::session::claude_code::list_claude_code_sessions() { + Ok(claude_sessions) => { + tracing::debug!("Found {} Claude Code sessions", claude_sessions.len()); + for (session_id, working_dir, updated_at) in claude_sessions { + sessions.push(Session { + id: session_id.clone(), + working_dir, + name: format!( + "Claude Code Session {}", + session_id.chars().take(8).collect::() + ), + user_set_name: false, + session_type: SessionType::User, + created_at: updated_at, + updated_at, + extension_data: ExtensionData::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation: None, + message_count: 0, + provider_name: None, + model_config: None, + }); + } + } + Err(e) => { + tracing::debug!("Failed to list Claude Code sessions: {}", e); + } + } + + match crate::session::codex::list_codex_sessions() { + Ok(codex_sessions) => { + tracing::debug!("Found {} Codex sessions", codex_sessions.len()); + for (session_id, working_dir, updated_at) in codex_sessions { + sessions.push(Session { + id: session_id.clone(), + working_dir, + name: format!( + "Codex Session {}", + session_id.chars().take(8).collect::() + ), + user_set_name: false, + session_type: SessionType::User, + created_at: updated_at, + updated_at, + extension_data: ExtensionData::default(), + total_tokens: None, + input_tokens: None, + output_tokens: None, + accumulated_total_tokens: None, + accumulated_input_tokens: None, + accumulated_output_tokens: None, + schedule_id: None, + recipe: None, + user_recipe_values: None, + conversation: None, + message_count: 0, + provider_name: None, + model_config: None, + }); + } + } + Err(e) => { + tracing::debug!("Failed to list Codex sessions: {}", e); + } + } + + tracing::debug!("Returning {} external sessions", sessions.len()); + sessions +} diff --git a/crates/goose/src/session/mod.rs b/crates/goose/src/session/mod.rs index b8b7c8d5a2..b1c8b02254 100644 --- a/crates/goose/src/session/mod.rs +++ b/crates/goose/src/session/mod.rs @@ -1,6 +1,9 @@ mod chat_history_search; +mod claude_code; +mod codex; mod diagnostics; pub mod extension_data; +mod external_sessions; mod legacy; pub mod session_manager; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 7d63e665b4..27d0676123 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -884,7 +884,7 @@ impl SessionStorage { } async fn get_session(&self, id: &str, include_messages: bool) -> Result { - let mut session = sqlx::query_as::<_, Session>( + let session_opt = sqlx::query_as::<_, Session>( r#" SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data, total_tokens, input_tokens, output_tokens, @@ -897,8 +897,19 @@ impl SessionStorage { ) .bind(id) .fetch_optional(&self.pool) - .await? - .ok_or_else(|| anyhow::anyhow!("Session not found"))?; + .await?; + + let mut session = if let Some(s) = session_opt { + s + } else { + use crate::session::external_sessions; + + if let Some(s) = external_sessions::get_external_session(id, include_messages) { + return Ok(s); + } + + return Err(anyhow::anyhow!("Session not found")); + }; if include_messages { let conv = self.get_conversation(&session.id).await?; @@ -1122,7 +1133,7 @@ impl SessionStorage { } async fn list_sessions(&self) -> Result> { - sqlx::query_as::<_, Session>( + let mut sessions = sqlx::query_as::<_, Session>( r#" SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, s.total_tokens, s.input_tokens, s.output_tokens, @@ -1138,8 +1149,13 @@ impl SessionStorage { "#, ) .fetch_all(&self.pool) - .await - .map_err(Into::into) + .await?; + + use crate::session::external_sessions; + sessions.extend(external_sessions::get_external_sessions_for_list()); + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + + Ok(sessions) } async fn delete_session(&self, session_id: &str) -> Result<()> { diff --git a/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx b/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx index a9df69ccb6..416c438052 100644 --- a/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx +++ b/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx @@ -4,8 +4,10 @@ import { Check, Lock, Loader2, AlertCircle } from 'lucide-react'; import { Switch } from '../../ui/switch'; import { Button } from '../../ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; +import { useConfig } from '../../ConfigContext'; export default function SessionSharingSection() { + const { config, upsert } = useConfig(); const envBaseUrlShare = window.appConfig.get('GOOSE_BASE_URL_SHARE'); console.log('envBaseUrlShare', envBaseUrlShare); @@ -150,6 +152,14 @@ export default function SessionSharingSection() { } }; + const externalSessionsEnabled = + config['GOOSE_ENABLE_EXTERNAL_SESSIONS'] === true || + config['GOOSE_ENABLE_EXTERNAL_SESSIONS'] === 'true'; + + const toggleExternalSessions = async () => { + await upsert('GOOSE_ENABLE_EXTERNAL_SESSIONS', !externalSessionsEnabled, false); + }; + return (
@@ -247,6 +257,26 @@ export default function SessionSharingSection() { + + + + External Sessions + + Allow goose to access sessions from Claude Code and Codex editors. When enabled, goose + can read conversation history from these tools. + + + +
+ + +
+
+
); }