use core::fmt::Write as FmtWrite;
use unilang::{ VerifiedCommand, ExecutionContext, OutputData, ErrorData, ErrorCode };
use super::storage::{ create_storage, resolve_path_parameter };
const UUID_LEN : usize = 36;
const UUID_SHORT_LEN : usize = 8;
const AGENT_TYPE_UNKNOWN : &str = "unknown";
const SECS_PER_MIN : u64 = 60;
const SECS_PER_HOUR : u64 = 3_600;
const SECS_PER_DAY : u64 = 86_400;
const SECS_PER_MONTH : u64 = 2_592_000;
fn is_relevant_encoded( dir_name : &str, encoded_base : &str ) -> bool
{
let check = | candidate : &str | -> bool
{
encoded_base == candidate
|| encoded_base.starts_with( &format!( "{candidate}-" ) )
};
if check( dir_name ) { return true; }
let mut s = dir_name;
while let Some( idx ) = s.rfind( "--" )
{
s = &s[ ..idx ];
if check( s ) { return true; }
}
false
}
fn tilde_compress( path : &std::path::Path ) -> String
{
if let Ok( home ) = std::env::var( "HOME" )
{
if let Ok( rel ) = path.strip_prefix( std::path::Path::new( &home ) )
{
return format!( "~/{}", rel.display() );
}
}
path.display().to_string()
}
fn decode_path_via_fs( encoded : &str ) -> Option< std::path::PathBuf >
{
let inner = &encoded[ 1.. ]; let pieces : Vec< &str > = inner.split( '-' ).collect();
if pieces.is_empty() { return None; }
walk_fs( std::path::Path::new( "/" ), &pieces, 0, "" )
}
fn decode_storage_base( base_encoded : &str ) -> Option< std::path::PathBuf >
{
use claude_storage_core::decode_path;
let h = decode_path( base_encoded ).ok()?;
if h.exists()
{
Some( h )
}
else
{
Some( decode_path_via_fs( base_encoded ).unwrap_or( h ) )
}
}
fn topic_to_dir( topic : &str ) -> String
{
format!( "-{}", topic.replace( '-', "_" ) )
}
fn matches_under( dir_name : &str, eb : &str, base_path : &std::path::Path ) -> bool
{
if dir_name != eb && !dir_name.starts_with( &format!( "{eb}-" ) ) { return false; }
if dir_name == eb { return true; }
let candidate_base = strip_topic_suffix( dir_name );
decode_path_via_fs( candidate_base )
.map_or( true, | p | p.starts_with( base_path ) )
}
fn matches_relevant( dir_name : &str, eb : &str, base_path : &std::path::Path ) -> bool
{
if !is_relevant_encoded( dir_name, eb ) { return false; }
let candidate_base = strip_topic_suffix( dir_name );
if candidate_base == eb { return true; }
decode_path_via_fs( candidate_base )
.map_or( true, | p | base_path.starts_with( &p ) )
}
fn strip_topic_suffix( dir_name : &str ) -> &str
{
dir_name.find( "--" ).map_or( dir_name, | i | &dir_name[ ..i ] )
}
fn split_storage_key< 'a >( dir_name : &'a str ) -> ( &'a str, Vec< &'a str > )
{
let mut parts : Vec< &'a str > = Vec::new();
let mut rest = dir_name;
loop
{
if let Some( idx ) = rest.find( "--" )
{
parts.push( &rest[ ..idx ] );
rest = &rest[ idx + 2.. ];
}
else
{
parts.push( rest );
break;
}
}
let base = parts[ 0 ];
let topics = parts[ 1.. ].to_vec();
( base, topics )
}
fn walk_fs(
base : &std::path::Path,
pieces : &[ &str ],
idx : usize,
segment : &str,
) -> Option< std::path::PathBuf >
{
if idx == pieces.len()
{
let candidate = if segment.is_empty() { base.to_path_buf() } else { base.join( segment ) };
return if candidate.exists() { Some( candidate ) } else { None };
}
let piece = pieces[ idx ];
if !segment.is_empty()
{
let next_base = base.join( segment );
if next_base.is_dir()
{
if let Some( result ) = walk_fs( &next_base, pieces, idx + 1, piece )
{
return Some( result );
}
}
}
let joined = if segment.is_empty()
{
piece.to_string()
}
else
{
format!( "{segment}_{piece}" )
};
walk_fs( base, pieces, idx + 1, &joined )
}
fn decode_project_display( dir_name : &str ) -> String
{
if !dir_name.starts_with( '-' ) { return dir_name.to_string(); }
let ( base_encoded, topics ) = split_storage_key( dir_name );
let Some( base_path ) = decode_storage_base( base_encoded ) else { return dir_name.to_string() };
let mut current = base_path;
for &topic in &topics
{
current = current.join( topic_to_dir( topic ) );
}
tilde_compress( ¤t )
}
fn session_mtime( session : &claude_storage_core::Session ) -> Option< std::time::SystemTime >
{
std::fs::metadata( session.storage_path() )
.ok()
.and_then( | m | m.modified().ok() )
}
fn is_zero_byte_session( session : &claude_storage_core::Session ) -> bool
{
std::fs::metadata( session.storage_path() )
.is_ok_and( | m | m.len() == 0 )
}
fn short_id( id : &str ) -> &str
{
if id.len() == UUID_LEN && id.as_bytes().get( UUID_SHORT_LEN ) == Some( &b'-' ) { &id[ ..UUID_SHORT_LEN ] }
else { id }
}
fn format_relative_time( mtime : std::time::SystemTime ) -> String
{
let elapsed = std::time::SystemTime::now()
.duration_since( mtime )
.unwrap_or_default();
let secs = elapsed.as_secs();
if secs < SECS_PER_MIN { format!( "{secs}s ago" ) }
else if secs < SECS_PER_HOUR { format!( "{}m ago", secs / SECS_PER_MIN ) }
else if secs < SECS_PER_DAY { format!( "{}h ago", secs / SECS_PER_HOUR ) }
else if secs < SECS_PER_MONTH { format!( "{}d ago", secs / SECS_PER_DAY ) }
else { format!( "{}mo ago", secs / SECS_PER_MONTH ) }
}
struct AgentMeta { agent_type : String }
struct AgentInfo
{
session : claude_storage_core::Session,
agent_type : String,
}
pub( super ) struct SessionFamily
{
root : Option< claude_storage_core::Session >,
agents : Vec< AgentInfo >,
}
pub struct Conversation
{
families : Vec< SessionFamily >,
}
impl core::fmt::Debug for Conversation
{
#[ inline ]
fn fmt( &self, f : &mut core::fmt::Formatter< '_ > ) -> core::fmt::Result
{
f.debug_struct( "Conversation" )
.field( "family_count", &self.conversation_count() )
.finish()
}
}
impl Conversation
{
pub( super ) fn root_session( &self ) -> Option< &claude_storage_core::Session >
{
self.families.first().and_then( | f | f.root.as_ref() )
}
fn all_agents( &self ) -> impl Iterator< Item = &AgentInfo >
{
self.families.iter().flat_map( | f | f.agents.iter() )
}
fn conversation_count( &self ) -> usize
{
self.families.len()
}
}
pub( super ) fn group_into_conversations( families : Vec< SessionFamily > ) -> Vec< Conversation >
{
families
.into_iter()
.map( | family | Conversation { families : vec![ family ] } )
.collect()
}
struct ProjectSummary
{
display_path : String,
last_mtime : std::time::SystemTime,
}
fn parse_agent_meta( agent_path : &std::path::Path ) -> AgentMeta
{
let meta_path = agent_path.with_extension( "meta.json" );
let content = match std::fs::read_to_string( &meta_path )
{
Ok( c ) if !c.is_empty() => c,
_ => return AgentMeta { agent_type : AGENT_TYPE_UNKNOWN.into() },
};
let Ok( val ) = claude_storage_core::parse_json( &content ) else
{
return AgentMeta { agent_type : AGENT_TYPE_UNKNOWN.into() };
};
let agent_type = val.as_object()
.and_then( | obj | obj.get( "agentType" ) )
.and_then( claude_storage_core::JsonValue::as_str )
.filter( | s | !s.trim().is_empty() )
.unwrap_or( AGENT_TYPE_UNKNOWN )
.to_string();
AgentMeta { agent_type }
}
fn extract_parent_hierarchical( agent_path : &std::path::Path ) -> Option< String >
{
agent_path
.parent()? .parent()? .file_name()?
.to_str()
.map( String::from )
}
fn extract_parent_flat( agent_path : &std::path::Path ) -> Option< String >
{
use std::io::BufRead;
let file = std::fs::File::open( agent_path ).ok()?;
let mut reader = std::io::BufReader::new( file );
let mut line = String::new();
reader.read_line( &mut line ).ok()?;
let val = claude_storage_core::parse_json( &line ).ok()?;
val.as_object()?
.get( "sessionId" )?
.as_str()
.map( String::from )
}
fn is_hierarchical_format( agents : &[ &claude_storage_core::Session ] ) -> bool
{
agents.iter().any( | s |
s.storage_path().components().any( | c | c.as_os_str() == "subagents" )
)
}
fn resolve_agent_parents(
agents : Vec< claude_storage_core::Session >,
) -> ( std::collections::HashMap< String, Vec< AgentInfo > >, Vec< AgentInfo > )
{
use std::collections::HashMap;
let agent_refs : Vec< &claude_storage_core::Session > = agents.iter().collect();
let hierarchical = is_hierarchical_format( &agent_refs );
let mut parent_map : HashMap< String, Vec< AgentInfo > > = HashMap::new();
let mut orphans : Vec< AgentInfo > = Vec::new();
for agent in agents
{
let meta = parse_agent_meta( agent.storage_path() );
let parent_id = if hierarchical
{
extract_parent_hierarchical( agent.storage_path() )
}
else
{
extract_parent_flat( agent.storage_path() )
};
let info = AgentInfo { session : agent, agent_type : meta.agent_type };
match parent_id
{
Some( pid ) => parent_map.entry( pid ).or_default().push( info ),
None => orphans.push( info ),
}
}
( parent_map, orphans )
}
pub( super ) fn build_families(
sessions : Vec< claude_storage_core::Session >,
) -> Vec< SessionFamily >
{
let mut roots : Vec< claude_storage_core::Session > = Vec::new();
let mut agents : Vec< claude_storage_core::Session > = Vec::new();
for s in sessions
{
if s.is_agent_session() { agents.push( s ); }
else { roots.push( s ); }
}
if agents.is_empty()
{
return roots.into_iter()
.map( | r | SessionFamily { root : Some( r ), agents : Vec::new() } )
.collect();
}
let ( mut parent_map, mut orphan_agents ) = resolve_agent_parents( agents );
let mut families : Vec< SessionFamily > = Vec::new();
for root in roots
{
let children = parent_map.remove( root.id() ).unwrap_or_default();
families.push( SessionFamily { root : Some( root ), agents : children } );
}
for ( _pid, agents_vec ) in parent_map
{
orphan_agents.extend( agents_vec );
}
if !orphan_agents.is_empty()
{
families.push( SessionFamily { root : None, agents : orphan_agents } );
}
families.sort_by( | a, b |
{
let ta = a.root.as_ref().and_then( session_mtime )
.unwrap_or( std::time::UNIX_EPOCH );
let tb = b.root.as_ref().and_then( session_mtime )
.unwrap_or( std::time::UNIX_EPOCH );
tb.cmp( &ta )
} );
families
}
fn format_type_breakdown( agents : &[ AgentInfo ] ) -> String
{
use std::collections::HashMap;
let mut counts : HashMap< &str, usize > = HashMap::new();
for a in agents
{
*counts.entry( a.agent_type.as_str() ).or_default() += 1;
}
let mut pairs : Vec< ( &str, usize ) > = counts.into_iter().collect();
pairs.sort_by( | a, b | b.1.cmp( &a.1 ).then_with( || a.0.cmp( b.0 ) ) );
pairs.iter()
.map( | ( t, n ) | format!( "{n}\u{00d7}{t}" ) )
.collect::< Vec< _ > >()
.join( ", " )
}
fn aggregate_projects(
groups : &mut std::collections::BTreeMap< String, Vec< claude_storage_core::Session > >,
) -> Vec< ProjectSummary >
{
let mut summaries : Vec< ProjectSummary > = Vec::new();
for ( display_path, sessions ) in groups.iter_mut()
{
let best = sessions
.iter()
.enumerate()
.filter( | ( _, s ) | !is_zero_byte_session( s ) )
.filter_map( | ( i, s ) | session_mtime( s ).map( | t | ( i, t ) ) )
.max_by_key( | &( _, t ) | t );
let Some( ( _, best_time ) ) = best else { continue };
summaries.push( ProjectSummary
{
display_path : display_path.clone(),
last_mtime : best_time,
} );
}
summaries.sort_by_key( | b | core::cmp::Reverse( b.last_mtime ) );
summaries
}
#[ allow( clippy::needless_pass_by_value ) ]
#[ allow( clippy::too_many_lines ) ]
#[ inline ]
pub fn projects_routine( cmd : VerifiedCommand, _ctx : ExecutionContext )
-> core::result::Result< OutputData, ErrorData >
{
use std::collections::BTreeMap;
use std::path::PathBuf;
use claude_storage_core::{ Session, SessionFilter, encode_path };
let scope_raw = cmd.get_string( "scope" ).unwrap_or( "around" );
let scope = scope_raw.to_lowercase();
if !matches!( scope.as_str(), "local" | "relevant" | "under" | "around" | "global" )
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "scope must be relevant|local|under|around|global, got {scope_raw}" ),
) );
}
let show_tree = cmd.get_boolean( "show_tree" ).unwrap_or( false );
let verbosity_raw = cmd.get_integer( "verbosity" ).unwrap_or( 1 );
if !( 0..=5 ).contains( &verbosity_raw )
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "Invalid verbosity: {verbosity_raw}. Valid range: 0-5" ),
) );
}
let verbosity = if show_tree && verbosity_raw < 2 { 2 } else { verbosity_raw };
let min_entries_filter = if let Some( n ) = cmd.get_integer( "min_entries" )
{
if n < 0
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "Invalid min_entries: {n}. Must be non-negative" ),
) );
}
#[ allow( clippy::cast_sign_loss, clippy::cast_possible_truncation ) ]
Some( n as usize )
}
else { None };
let limit_cap = if let Some( n ) = cmd.get_integer( "limit" )
{
if n < 0
{
return Err( ErrorData::new(
ErrorCode::InternalError,
format!( "Invalid limit: {n}. Must be non-negative" ),
) );
}
#[ allow( clippy::cast_sign_loss, clippy::cast_possible_truncation ) ]
let v = n as usize;
if v == 0 { usize::MAX } else { v }
}
else { usize::MAX };
let agent_filter = cmd.get_boolean( "agent" );
let session_id_filter = cmd.get_string( "session" );
let base_path : PathBuf = if let Some( p ) = cmd.get_string( "path" )
{
resolve_path_parameter( p )
.map( PathBuf::from )
.map_err( | e | ErrorData::new(
ErrorCode::InternalError,
format!( "Failed to resolve path '{p}': {e}" ),
) )?
}
else
{
std::env::current_dir()
.map_err( | e | ErrorData::new(
ErrorCode::InternalError,
format!( "Failed to get current directory: {e}" ),
) )?
};
let storage = create_storage()?;
let all_projects = storage.list_projects()
.map_err( | e | ErrorData::new( ErrorCode::InternalError, format!( "Failed to list projects: {e}" ) ) )?;
let encoded_base : Option< String > = if scope == "global"
{
None
}
else
{
Some(
encode_path( &base_path )
.map_err( | e | ErrorData::new(
ErrorCode::InternalError,
format!( "Failed to encode base path '{}': {e}", base_path.display() ),
) )?
)
};
let project_matches = | project : &claude_storage_core::Project | -> bool
{
if scope == "global" { return true; }
let Some( ref eb ) = encoded_base else { return false };
let dir_name = project
.storage_dir()
.file_name()
.and_then( | n | n.to_str() )
.unwrap_or( "" );
match scope.as_str()
{
"local" => dir_name == eb || dir_name.starts_with( &format!( "{eb}--" ) ),
"under" => matches_under( dir_name, eb, &base_path ),
"relevant" => matches_relevant( dir_name, eb, &base_path ),
"around" =>
matches_under( dir_name, eb, &base_path )
|| matches_relevant( dir_name, eb, &base_path ),
_ => false,
}
};
let session_filter = SessionFilter
{
agent_only : agent_filter,
min_entries : min_entries_filter,
session_id_substring : session_id_filter.map( std::string::ToString::to_string ),
};
let mut groups : BTreeMap< String, Vec< Session > > = BTreeMap::new();
for mut project in all_projects
{
if !project_matches( &project ) { continue; }
let dir_name = project
.storage_dir()
.file_name()
.and_then( | n | n.to_str() )
.unwrap_or( "" )
.to_string();
let display_path = decode_project_display( &dir_name );
let Ok( sessions ) = project.sessions_filtered( &session_filter ) else { continue };
if sessions.is_empty() { continue; }
groups
.entry( display_path )
.or_default()
.extend( sessions );
}
for sessions in groups.values_mut()
{
sessions.sort_by( | a, b |
{
let ta = session_mtime( a ).unwrap_or( std::time::UNIX_EPOCH );
let tb = session_mtime( b ).unwrap_or( std::time::UNIX_EPOCH );
tb.cmp( &ta )
} );
}
let summaries = aggregate_projects( &mut groups );
let total_projects = summaries.len();
let mut output = String::new();
if verbosity == 0
{
for summary in &summaries
{
writeln!( output, "{}", summary.display_path ).unwrap();
}
return Ok( OutputData::new( output, "text" ) );
}
let use_families = agent_filter.is_none();
let p_noun = if total_projects == 1 { "project" } else { "projects" };
writeln!( output, "Found {total_projects} {p_noun}:\n" ).unwrap();
for summary in summaries
{
let sessions = groups.remove( &summary.display_path ).unwrap_or_default();
let display_path = &summary.display_path;
if use_families
{
let families = build_families( sessions );
let conversations = group_into_conversations( families );
let root_count = conversations
.iter()
.filter( | c | c.root_session().is_some_and( | s | !is_zero_byte_session( s ) ) )
.count();
let agent_count : usize = conversations.iter().map( | c | c.all_agents().count() ).sum();
let families : Vec< SessionFamily > = conversations
.into_iter()
.flat_map( | c | c.families )
.collect();
let r_noun = if root_count == 1 { "conversation" } else { "conversations" };
if agent_count > 0
{
let a_noun = if agent_count == 1 { "agent" } else { "agents" };
writeln!( output, "{display_path}: ({root_count} {r_noun}, {agent_count} {a_noun})" ).unwrap();
}
else
{
writeln!( output, "{display_path}: ({root_count} {r_noun})" ).unwrap();
}
if verbosity >= 2
{
render_families_v2( &mut output, &families );
}
else
{
render_families_v1( &mut output, &families, limit_cap );
}
}
else
{
let displayable : Vec< &Session > = sessions
.iter()
.filter( | &s | s.is_agent_session() || !is_zero_byte_session( s ) )
.collect();
let group_count = displayable.len();
let group_noun = if group_count == 1 { "conversation" } else { "conversations" };
writeln!( output, "{display_path}: ({group_count} {group_noun})" ).unwrap();
let show_count = displayable.len().min( limit_cap );
for ( i, &session ) in displayable[ ..show_count ].iter().enumerate()
{
let marker = if i == 0 { '*' } else { '-' };
let id_str = short_id( session.id() );
let time_str = session_mtime( session )
.map( | t | format!( " {}", format_relative_time( t ) ) )
.unwrap_or_default();
let count_str = session
.count_entries()
.map( | n |
{
let noun = if n == 1 { "entry" } else { "entries" };
format!( " ({n} {noun})" )
} )
.unwrap_or_default();
writeln!( output, " {marker} {id_str}{time_str}{count_str}" ).unwrap();
}
if displayable.len() > limit_cap
{
let hidden = displayable.len() - limit_cap;
let hidden_noun = if hidden == 1 { "conversation" } else { "conversations" };
writeln!(
output,
" ... and {hidden} more {hidden_noun} (use limit::0 to list all)"
).unwrap();
}
}
writeln!( output ).unwrap();
}
Ok( OutputData::new( output, "text" ) )
}
fn format_agent_bracket( agents : &[ AgentInfo ] ) -> String
{
if agents.is_empty() { return String::new(); }
let n = agents.len();
let noun = if n == 1 { "agent" } else { "agents" };
let breakdown = format_type_breakdown( agents );
format!( " [{n} {noun}: {breakdown}]" )
}
fn format_session_line(
session : &claude_storage_core::Session,
marker : char,
) -> String
{
let id_str = short_id( session.id() );
let time_str = session_mtime( session )
.map( | t | format!( " {}", format_relative_time( t ) ) )
.unwrap_or_default();
let count_str = session
.count_entries()
.map( | n |
{
let noun = if n == 1 { "entry" } else { "entries" };
format!( " ({n} {noun})" )
} )
.unwrap_or_default();
format!( " {marker} {id_str}{time_str}{count_str}" )
}
fn render_families_v1(
output : &mut String,
families : &[ SessionFamily ],
limit_cap : usize,
)
{
let displayable : Vec< &SessionFamily > = families.iter()
.filter( | f | !f.root.as_ref().is_some_and( is_zero_byte_session ) )
.collect();
let show_count = displayable.len().min( limit_cap );
for ( i, family ) in displayable[ ..show_count ].iter().enumerate()
{
if let Some( root ) = &family.root
{
let marker = if i == 0 { '*' } else { '-' };
let line = format_session_line( root, marker );
let bracket = format_agent_bracket( &family.agents );
writeln!( output, "{line}{bracket}" ).unwrap();
}
else
{
let bracket = format_agent_bracket( &family.agents );
writeln!( output, " ? (orphan){bracket}" ).unwrap();
}
}
if displayable.len() > limit_cap
{
let hidden = displayable.len() - limit_cap;
let noun = if hidden == 1 { "conversation" } else { "conversations" };
writeln!( output, " ... and {hidden} more {noun} (use limit::0 to list all)" ).unwrap();
}
}
fn render_families_v2(
output : &mut String,
families : &[ SessionFamily ],
)
{
for family in families
{
if let Some( root ) = &family.root
{
let id = root.id();
let count_str = root
.count_entries()
.map( | n | {
let noun = if n == 1 { "entry" } else { "entries" };
format!( " ({n} {noun})" )
} )
.unwrap_or_default();
writeln!( output, " - {id}{count_str}" ).unwrap();
}
else
{
writeln!( output, " ? (orphan agents)" ).unwrap();
}
for ( j, agent ) in family.agents.iter().enumerate()
{
let connector = if j + 1 < family.agents.len() { "\u{251c}\u{2500}" } else { "\u{2514}\u{2500}" };
let aid = agent.session.id();
let atype = &agent.agent_type;
let acount = agent.session
.count_entries()
.map( | n | {
let noun = if n == 1 { "entry" } else { "entries" };
format!( " {n} {noun}" )
} )
.unwrap_or_default();
writeln!( output, " {connector} {aid} {atype}{acount}" ).unwrap();
}
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
#[ test ]
fn it_conversation_groups_families_one_to_one()
{
let result = group_into_conversations( vec![] );
assert_eq!( result.len(), 0, "Expected 0 conversations for 0 families" );
for conv in &result
{
let _ = conv.root_session();
let _ = conv.all_agents().count();
let _ = conv.conversation_count();
}
}
}