Skip to content

Conversation

@michaelneale
Copy link
Collaborator

This lets you turn on (off by default) loading of claude and codex sessions. It doesn't import them until you click "resume" in goose (then it will run them as a goose session).

Copilot AI review requested due to automatic review settings November 25, 2025 07:14
Copilot finished reviewing on behalf of michaelneale November 25, 2025 07:18
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements external session loading from Claude Code and Codex editors, controlled by a new GOOSE_ENABLE_EXTERNAL_SESSIONS configuration flag (off by default). When enabled, Goose can discover and display sessions from these external tools in the session list. The actual conversation history is only loaded when a user explicitly resumes a session.

Key Changes:

  • Added UI toggle in settings to enable/disable external session access
  • Integrated external session loading into session manager's fallback logic for get_session() and list_sessions()
  • Implemented parsers for Claude Code (.claude/projects/) and Codex (.codex/sessions/) session file formats

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx Adds External Sessions card with toggle switch to enable/disable feature
crates/goose/src/session/session_manager.rs Integrates external session fallback in get_session() and extends session list with external sessions
crates/goose/src/session/mod.rs Adds module declarations for claude_code, codex, and external_sessions
crates/goose/src/session/external_sessions.rs Implements feature flag check and orchestrates Claude Code and Codex session loading
crates/goose/src/session/codex.rs Parses Codex JSONL session files from nested date-based directory structure
crates/goose/src/session/claude_code.rs Parses Claude Code JSONL session files from project-based directory structure
crates/goose/src/config/base.rs Adds GOOSE_ENABLE_EXTERNAL_SESSIONS boolean configuration value

Comment on lines +28 to +120
fn get_claude_code_session(id: &str, include_messages: bool) -> Option<Session> {
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::<String>()
),
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<Session> {
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::<String>()
),
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
}
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both get_claude_code_session and get_codex_session contain nearly identical logic with only the function calls and session name prefix differing. Extract this to a shared helper function to reduce duplication and improve maintainability.

Copilot uses AI. Check for mistakes.
Comment on lines +136 to +198
sessions.push(Session {
id: session_id.clone(),
working_dir,
name: format!(
"Claude Code Session {}",
session_id.chars().take(8).collect::<String>()
),
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::<String>()
),
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,
});
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In get_external_sessions_for_list(), the Session construction logic is duplicated for both Claude Code and Codex sessions (lines 136-161 and 173-198). This is identical code except for the session name prefix. Extract this into a helper function that takes the session metadata and name prefix as parameters.

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +87
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)
}
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested directory traversal (2 levels deep) in list_claude_code_sessions() will be slow and I/O intensive. This code is called on every list_sessions() request. Consider caching the results or making this an opt-in background operation.

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +150
pub fn load_claude_code_session(session_id: &str) -> Result<Conversation> {
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"))
}
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The load_claude_code_session() function performs a full filesystem walk through potentially many directories and files every time it's called. Consider optimizing by either: (1) maintaining an index/cache of session file locations, or (2) deriving the file path from session metadata if possible.

Copilot uses AI. Check for mistakes.
Comment on lines +1154 to +1156
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));
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_external_sessions_for_list() performs blocking file I/O but is called from an async context in list_sessions(). This will block the tokio runtime thread. Wrap the synchronous I/O calls in tokio::task::spawn_blocking() to prevent blocking the async runtime.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +72
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::<String>()
),
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
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_claude_code_session lists all sessions and iterates through them to find a match. This is inefficient - when include_messages is true, it calls list_claude_code_sessions() (which walks the entire filesystem), then calls load_claude_code_session() (which walks the filesystem again). Consider having load_claude_code_session directly locate and load the session file by ID.

Suggested change
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::<String>()
),
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
// Try to load the session directly by ID
let loaded = crate::session::claude_code::load_claude_code_session(id).ok()?;
// Assume load_claude_code_session returns a struct with working_dir, updated_at, created_at, etc.
let message_count = loaded.messages().len();
Some(Session {
id: id.to_string(),
working_dir: loaded.working_dir().clone(),
name: format!(
"Claude Code Session {}",
id.chars().take(8).collect::<String>()
),
user_set_name: false,
session_type: SessionType::User,
created_at: loaded.created_at(),
updated_at: loaded.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: if include_messages { Some(loaded) } else { None },
message_count,
provider_name: None,
model_config: None,
})

Copilot uses AI. Check for mistakes.
Comment on lines +76 to +119
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::<String>()
),
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
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_codex_session lists all sessions and iterates through them to find a match. This is inefficient - when include_messages is true, it calls list_codex_sessions() (which walks the entire filesystem), then calls load_codex_session() (which walks the filesystem again). Consider having load_codex_session directly locate and load the session file by ID.

Suggested change
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::<String>()
),
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
if include_messages {
// Directly load the session by ID, avoiding a full filesystem walk.
let loaded = crate::session::codex::load_codex_session(id).ok()?;
// If load_codex_session returns a struct with working_dir and updated_at, use them.
// Otherwise, you may need to update load_codex_session to return these fields.
let working_dir = loaded.working_dir().clone();
let updated_at = loaded.updated_at();
let message_count = loaded.messages().len();
return Some(Session {
id: id.to_string(),
working_dir,
name: format!(
"Codex Session {}",
id.chars().take(8).collect::<String>()
),
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: Some(loaded),
message_count,
provider_name: None,
model_config: None,
});
} else {
let sessions = crate::session::codex::list_codex_sessions().ok()?;
for (session_id, working_dir, updated_at) in sessions {
if session_id == id {
return Some(Session {
id: session_id.clone(),
working_dir,
name: format!(
"Codex Session {}",
session_id.chars().take(8).collect::<String>()
),
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,
});
}
}
None
}

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +39
fn get_home_dir() -> Option<PathBuf> {
std::env::var("HOME")
.ok()
.map(PathBuf::from)
.or_else(dirs::home_dir)
}
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_home_dir() function is duplicated in both claude_code.rs and codex.rs. Extract this to a shared utility module to avoid duplication.

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +112
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)
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested directory traversal (3-4 levels deep) in list_codex_sessions() will be slow and I/O intensive. This code is called on every list_sessions() request. Consider caching the results or making this an opt-in background operation.

Copilot uses AI. Check for mistakes.
Comment on lines +160 to +205
// 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"))
}

Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The load_codex_session() function performs a full filesystem walk through potentially hundreds of directories and files every time it's called. Consider optimizing by either: (1) maintaining an index/cache of session file locations, or (2) deriving the file path from session metadata if possible.

Suggested change
// 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"))
}
// Attempt to derive the file path directly from the session ID
if let Some(session_path) = derive_session_file_path(&sessions_dir, session_id) {
if session_path.is_file() {
return parse_conversation(&session_path);
}
}
Err(anyhow::anyhow!("Session not found"))
}
/// Attempts to derive the session file path from the session ID.
/// Assumes session_id format: YYYYMMDD-<unique_id>
fn derive_session_file_path(sessions_dir: &PathBuf, session_id: &str) -> Option<PathBuf> {
// Example session_id: "20240601-abcdef123456"
if session_id.len() < 8 {
return None;
}
let year = &session_id[0..4];
let month = &session_id[4..6];
let day = &session_id[6..8];
// The rest is the unique id
let unique = &session_id[9..]; // skip the dash
// Compose the expected file name
let file_name = format!("{}-{}.jsonl", &session_id[0..8], unique);
let file_path = sessions_dir
.join(year)
.join(month)
.join(day)
.join(file_name);
Some(file_path)
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants