mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
optional reading of external sessions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(rename = "sessionId")]
|
||||
session_id: Option<String>,
|
||||
cwd: Option<String>,
|
||||
timestamp: Option<String>,
|
||||
message: Option<ClaudeMessageWrapper>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClaudeMessageWrapper {
|
||||
role: Option<String>,
|
||||
content: serde_json::Value,
|
||||
}
|
||||
|
||||
fn get_home_dir() -> Option<PathBuf> {
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(dirs::home_dir)
|
||||
}
|
||||
|
||||
pub fn list_claude_code_sessions() -> Result<Vec<(String, PathBuf, DateTime<Utc>)>> {
|
||||
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<Utc>)> {
|
||||
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::<ClaudeJsonLine>(&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<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"))
|
||||
}
|
||||
|
||||
fn parse_conversation(file_path: &PathBuf) -> Result<Conversation> {
|
||||
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::<ClaudeJsonLine>(&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<MessageContent> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: String,
|
||||
payload: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SessionMeta {
|
||||
id: String,
|
||||
cwd: String,
|
||||
timestamp: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseItem {
|
||||
#[serde(rename = "type")]
|
||||
item_type: String,
|
||||
role: Option<String>,
|
||||
content: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn get_home_dir() -> Option<PathBuf> {
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(dirs::home_dir)
|
||||
}
|
||||
|
||||
pub fn list_codex_sessions() -> Result<Vec<(String, PathBuf, DateTime<Utc>)>> {
|
||||
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<Utc>)> {
|
||||
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::<CodexJsonLine>(&line) {
|
||||
// Parse session metadata from first line
|
||||
if entry.entry_type == "session_meta" {
|
||||
if let Ok(meta) = serde_json::from_value::<SessionMeta>(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<Conversation> {
|
||||
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<Conversation> {
|
||||
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::<CodexJsonLine>(&line) {
|
||||
// Only process response_item entries
|
||||
if entry.entry_type == "response_item" {
|
||||
if let Ok(item) = serde_json::from_value::<ResponseItem>(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::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
if !text.starts_with("<environment_context>") {
|
||||
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<MessageContent> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Session> {
|
||||
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<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
|
||||
}
|
||||
|
||||
pub fn get_external_sessions_for_list() -> Vec<Session> {
|
||||
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::<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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Failed to list Codex sessions: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("Returning {} external sessions", sessions.len());
|
||||
sessions
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -884,7 +884,7 @@ impl SessionStorage {
|
||||
}
|
||||
|
||||
async fn get_session(&self, id: &str, include_messages: bool) -> Result<Session> {
|
||||
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<Vec<Session>> {
|
||||
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<()> {
|
||||
|
||||
@@ -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 (
|
||||
<section id="session-sharing" className="space-y-4 pr-4 mt-1">
|
||||
<Card className="pb-2">
|
||||
@@ -247,6 +257,26 @@ export default function SessionSharingSection() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="pb-2">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle>External Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Allow goose to access sessions from Claude Code and Codex editors. When enabled, goose
|
||||
can read conversation history from these tools.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm cursor-pointer">Enable external session access</label>
|
||||
<Switch
|
||||
checked={externalSessionsEnabled}
|
||||
onCheckedChange={toggleExternalSessions}
|
||||
variant="mono"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user