use claude_core::process::{ find_claude_processes, ProcessInfo };
#[ cfg( target_os = "linux" ) ]
use claude_core::process::ProcessMetrics;
use data_fmt::{ RowBuilder, TableFormatter, TableConfig, TableCaption, Format };
struct PsConfig
{
mode : Option< String >,
columns : Option< String >,
wide : bool,
pids : Vec< u32 >,
inspect : bool,
ancient_secs : u64,
high_ram_mb : u64,
}
fn classify_mode( args : &[ String ] ) -> &str
{
if args.iter().any( | a | a == "--print" || a == "-p" )
{
"print"
}
else
{
"interactive"
}
}
#[ allow( clippy::too_many_lines ) ]
pub( crate ) fn dispatch_ps( tokens : &[ String ] ) -> !
{
let ( env_mode, env_columns, env_pids, env_ancient_secs, env_high_ram_mb )
= super::env::apply_ps_env_vars();
let mut config = PsConfig
{
mode : env_mode,
columns : env_columns,
wide : false,
pids : env_pids,
inspect : false,
ancient_secs : env_ancient_secs,
high_ram_mb : env_high_ram_mb,
};
let mut i = 1_usize; while i < tokens.len()
{
match tokens[ i ].as_str()
{
"--help" | "-h" | "help" =>
{
super::help::print_ps_help();
}
"--mode" | "-m" =>
{
i += 1;
if i >= tokens.len()
{
eprintln!( "clr ps: `--mode` requires a value (all|interactive|print)" );
std::process::exit( 1 );
}
config.mode = Some( tokens[ i ].clone() );
}
"--columns" =>
{
i += 1;
if i >= tokens.len()
{
eprintln!( "clr ps: `--columns` requires a value" );
std::process::exit( 1 );
}
config.columns = Some( tokens[ i ].clone() );
}
"--wide" | "-w" =>
{
config.wide = true;
}
"--pid" =>
{
i += 1;
if i >= tokens.len()
{
eprintln!( "clr ps: `--pid` requires a value (comma-separated PIDs)" );
std::process::exit( 1 );
}
let csv = tokens[ i ].clone();
let mut parsed_pids = Vec::new();
for part in csv.split( ',' )
{
let trimmed = part.trim();
if let Ok( pid ) = trimmed.parse::< u32 >()
{
parsed_pids.push( pid );
}
else
{
eprintln!( "clr ps: `--pid` value `{trimmed}` is not a valid PID; must be a positive integer" );
std::process::exit( 1 );
}
}
config.pids = parsed_pids;
}
"--inspect" | "-i" =>
{
config.inspect = true;
}
arg =>
{
eprintln!( "clr ps: unexpected argument `{arg}`\nRun 'clr ps --help' for usage." );
std::process::exit( 1 );
}
}
i += 1;
}
if let Some( ref m ) = config.mode
{
if !matches!( m.as_str(), "all" | "interactive" | "print" )
{
eprintln!(
"clr ps: invalid --mode value `{m}`; valid values: all, interactive, print"
);
std::process::exit( 1 );
}
}
if let Some( ref csv ) = config.columns
{
if let Err( msg ) = validate_columns( csv )
{
eprintln!( "clr ps: {msg}" );
std::process::exit( 1 );
}
}
let procs = find_claude_processes();
if config.inspect
{
let mode_str = config.mode.as_deref().unwrap_or( "all" );
let mode_ok : Vec< &ProcessInfo > = if mode_str == "all"
{
procs.iter().collect()
}
else
{
procs.iter().filter( | p | classify_mode( &p.args ) == mode_str ).collect()
};
let filtered : Vec< &ProcessInfo > = if config.pids.is_empty()
{
mode_ok
}
else
{
mode_ok.into_iter().filter( | p | config.pids.contains( &p.pid ) ).collect()
};
let output = build_inspect_output( &filtered );
if output.is_empty()
{
println!( "No active Claude Code sessions." );
}
else
{
println!( "{output}" );
}
std::process::exit( 0 );
}
#[ cfg( target_os = "linux" ) ]
let deltas : std::collections::HashMap< u32, u64 > = if procs.is_empty()
{
std::collections::HashMap::new()
}
else
{
let first : std::collections::HashMap< u32, u64 > = procs.iter()
.filter_map( |p| read_cpu_ticks( p.pid ).map( |t| ( p.pid, t ) ) )
.collect();
std::thread::sleep( core::time::Duration::from_secs( 1 ) );
procs.iter()
.filter_map( |p|
{
let t1 = first.get( &p.pid )?;
let t2 = read_cpu_ticks( p.pid )?;
Some( ( p.pid, t2.saturating_sub( *t1 ) ) )
} )
.collect()
};
#[ cfg( not( target_os = "linux" ) ) ]
let deltas : std::collections::HashMap< u32, u64 > = std::collections::HashMap::new();
let active_result = build_active_table( &procs, &config, &deltas );
let queued_table = build_queued_table();
match ( active_result, queued_table )
{
( None, None ) =>
{
println!( "No active Claude Code sessions." );
}
( Some( ( at, legend ) ), None ) =>
{
println!( "{at}" );
if let Some( leg ) = legend
{
println!();
println!( "{leg}" );
}
}
( None, Some( qt ) ) =>
{
println!( "No active Claude Code sessions." );
println!();
println!( "{qt}" );
}
( Some( ( at, legend ) ), Some( qt ) ) =>
{
println!( "{at}" );
if let Some( leg ) = legend
{
println!();
println!( "{leg}" );
}
println!();
println!( "{qt}" );
}
}
std::process::exit( 0 );
}
fn render_plain_table( builder : RowBuilder, caption : TableCaption ) -> String
{
let view = builder.build_view();
let probe = Format::format(
&TableFormatter::with_config( TableConfig::plain().auto_wrap( false ) ),
&view,
).unwrap_or_default();
let body_width = probe
.lines()
.find( |l| !l.trim().is_empty() )
.map_or( 120, |l| l.chars().count() );
Format::format(
&TableFormatter::with_config(
TableConfig::plain()
.auto_wrap( false )
.terminal_width( Some( body_width ) )
.caption( caption ),
),
&view,
).unwrap_or_default()
}
const COLUMN_KEYS : &[ ( &str, &str ) ] = &[
( "idx", "#" ),
( "pid", "PID" ),
( "elapsed", "Elapsed" ),
( "cpu", "CPU%" ),
( "ram", "RAM" ),
( "state", "State" ),
( "path", "Absolute Path" ),
( "task", "Task" ),
( "mode", "Mode" ),
( "cmd", "Command" ),
( "binary", "Binary" ),
];
const DEFAULT_COLUMNS : &[ &str ] = &[
"idx", "pid", "elapsed", "cpu", "ram", "state", "mode", "path", "task",
];
fn resolve_columns( config : &PsConfig ) -> Vec< &'static str >
{
if let Some( ref csv ) = config.columns
{
return match validate_columns( csv )
{
Ok( keys ) => keys,
Err( msg ) =>
{
eprintln!( "clr ps: {msg}" );
std::process::exit( 1 );
}
};
}
if config.wide
{
return COLUMN_KEYS.iter().map( | ( k, _ ) | *k ).collect();
}
DEFAULT_COLUMNS.to_vec()
}
fn validate_columns( csv : &str ) -> Result< Vec< &'static str >, String >
{
let mut out = Vec::new();
for raw in csv.split( ',' )
{
let key = raw.trim();
if let Some( ( k, _ ) ) = COLUMN_KEYS.iter().find( | ( k, _ ) | *k == key )
{
out.push( *k );
}
else
{
let valid : Vec< &str > = COLUMN_KEYS.iter().map( | ( k, _ ) | *k ).collect();
return Err( format!(
"unknown column key `{key}`; valid keys: {}",
valid.join( ", " )
) );
}
}
if out.is_empty()
{
let valid : Vec< &str > = COLUMN_KEYS.iter().map( | ( k, _ ) | *k ).collect();
return Err( format!( "no column keys given; valid keys: {}", valid.join( ", " ) ) );
}
Ok( out )
}
fn build_inspect_output( procs : &[ &ProcessInfo ] ) -> String
{
use core::fmt::Write as _;
let mut out = String::new();
for ( idx, proc ) in procs.iter().enumerate()
{
if idx > 0 { out.push( '\n' ); }
let pid = proc.pid;
let mode = classify_mode( &proc.args ).to_string();
let path = shorten_path( &proc.cwd.display().to_string() );
let task = resolve_task( proc );
let binary = proc.args.first().cloned().unwrap_or_default();
let cmd = proc.args.get( 1.. ).unwrap_or( &[] ).join( " " );
let cmdline = proc.args.join( " " );
#[ cfg( target_os = "linux" ) ]
let ( elapsed, cpu, ram, state, started ) =
{
use claude_core::process::read_process_metrics;
match read_process_metrics( pid )
{
Some( m ) => (
elapsed_label( m.started_at ),
format!( "{:.1}%", m.cpu_pct ),
ram_label( m.ram_kb ),
m.state.to_string(),
m.started_at.to_string(),
),
None => (
"-".to_string(), "-".to_string(), "-".to_string(),
"-".to_string(), "-".to_string(),
),
}
};
#[ cfg( not( target_os = "linux" ) ) ]
let ( elapsed, cpu, ram, state, started ) = (
"-".to_string(), "-".to_string(), "-".to_string(),
"-".to_string(), "-".to_string(),
);
let rule = format!( "──── PID {pid} {}", "─".repeat( 50 ) );
let _ = writeln!( out, "{rule}" );
let _ = writeln!( out, "{:<10}{pid}", "pid:" );
let _ = writeln!( out, "{:<10}{mode}", "mode:" );
let _ = writeln!( out, "{:<10}{elapsed}", "elapsed:" );
let _ = writeln!( out, "{:<10}{cpu}", "cpu:" );
let _ = writeln!( out, "{:<10}{ram}", "ram:" );
let _ = writeln!( out, "{:<10}{state}", "state:" );
let _ = writeln!( out, "{:<10}{path}", "path:" );
let _ = writeln!( out, "{:<10}{task}", "task:" );
let _ = writeln!( out, "{:<10}{binary}", "binary:" );
let _ = writeln!( out, "{:<10}{cmd}", "cmd:" );
let _ = writeln!( out, "{:<10}{cmdline}", "cmdline:" );
let _ = writeln!( out, "{:<10}{started}", "started:" );
}
out.trim_end_matches( '\n' ).to_string()
}
#[ cfg( target_os = "linux" ) ]
const FLAG_LEGEND : &[ ( &str, &str ) ] = &[
( "👈", "This session" ),
( "🖨", "Print mode" ),
( "⚡", "Active" ),
( "🕰", "Ancient" ),
( "🐘", "High RAM" ),
( "⚠", "Dead metrics" ),
( "🐳", "Container" ),
];
#[ cfg( target_os = "linux" ) ]
fn read_cpu_ticks( pid : u32 ) -> Option< u64 >
{
let data = std::fs::read_to_string( format!( "/proc/{pid}/stat" ) ).ok()?;
let close_paren = data.find( ')' )?;
let after_comm = &data[ close_paren + 2.. ]; let rest : Vec< &str > = after_comm.split_whitespace().collect();
let utime : u64 = rest.get( 11 )?.parse().ok()?;
let stime : u64 = rest.get( 12 )?.parse().ok()?;
Some( utime + stime )
}
#[ cfg( target_os = "linux" ) ]
fn push_flag( flags : &mut String, c : char )
{
if !flags.is_empty() { flags.push( ' ' ); }
flags.push( c );
}
fn compute_flags(
proc : &ProcessInfo,
metrics : Option< &ProcessMetrics >,
home : &str,
ancient_secs : u64,
high_ram_mb : u64,
my_ppid : u32,
cpu_delta_ticks : u64,
) -> String
{
let mut flags = String::new();
if proc.pid == my_ppid
{
let is_claude = std::fs::read( format!( "/proc/{my_ppid}/cmdline" ) )
.ok()
.and_then( | b |
{
let arg0 : Vec< u8 > = b.split( | &c | c == b'\0' )
.next()
.unwrap_or( &[] )
.to_vec();
String::from_utf8( arg0 ).ok()
} )
.is_some_and( | s |
{
std::path::Path::new( &s )
.file_name()
.and_then( | n | n.to_str() )
== Some( "claude" )
} );
if is_claude { push_flag( &mut flags, '👈' ); }
}
if classify_mode( &proc.args ) == "print" { push_flag( &mut flags, '🖨' ); }
if cpu_delta_ticks >= 3 { push_flag( &mut flags, '⚡' ); }
if let Some( m ) = metrics
{
let elapsed = super::gate::unix_now().saturating_sub( m.started_at );
if elapsed > ancient_secs { push_flag( &mut flags, '🕰' ); }
if m.ram_kb > high_ram_mb.saturating_mul( 1_024 ) { push_flag( &mut flags, '🐘' ); }
}
else
{
push_flag( &mut flags, '⚠' );
}
let cwd_str = proc.cwd.to_str().unwrap_or( "" );
if !home.is_empty() && !cwd_str.starts_with( home )
{
push_flag( &mut flags, '🐳' );
}
flags
}
#[ cfg( target_os = "linux" ) ]
fn build_legend( flags_per_row : &[ String ] ) -> String
{
let all_flags : String = flags_per_row.concat();
FLAG_LEGEND.iter()
.filter( | ( emoji, _ ) | all_flags.contains( *emoji ) )
.map( | ( emoji, name ) | format!( "{emoji} {name}" ) )
.collect::< Vec< _ > >()
.join( " " )
}
fn build_active_table(
procs : &[ ProcessInfo ],
config : &PsConfig,
deltas : &std::collections::HashMap< u32, u64 >,
) -> Option< ( String, Option< String > ) >
{
let mode = config.mode.as_deref().unwrap_or( "all" );
let mode_filtered : Vec< &ProcessInfo > = if mode == "all"
{
procs.iter().collect()
}
else
{
procs.iter().filter( | p | classify_mode( &p.args ) == mode ).collect()
};
let filtered : Vec< &ProcessInfo > = if config.pids.is_empty()
{
mode_filtered
}
else
{
mode_filtered.into_iter().filter( | p | config.pids.contains( &p.pid ) ).collect()
};
if filtered.is_empty() { return None; }
#[ cfg( target_os = "linux" ) ]
let sorted : Vec< &ProcessInfo > = {
use claude_core::process::read_process_metrics;
let mut v : Vec< &ProcessInfo > = filtered;
v.sort_by_key( |p| read_process_metrics( p.pid )
.map_or( u64::MAX, |m| m.started_at ) );
v
};
#[ cfg( not( target_os = "linux" ) ) ]
let sorted : Vec< &ProcessInfo > = filtered;
#[ cfg( target_os = "linux" ) ]
let flags_per_row : Vec< String > = {
use claude_core::process::read_process_metrics;
let home = std::env::var( "HOME" ).unwrap_or_default();
let my_ppid : u32 = std::os::unix::process::parent_id();
sorted.iter().map( | proc |
{
let m = read_process_metrics( proc.pid );
let cpu_delta = deltas.get( &proc.pid ).copied().unwrap_or( 0 );
compute_flags( proc, m.as_ref(), &home, config.ancient_secs, config.high_ram_mb, my_ppid, cpu_delta )
} ).collect()
};
#[ cfg( not( target_os = "linux" ) ) ]
let flags_per_row : Vec< String > = sorted.iter().map( |_| String::new() ).collect();
let any_flags = flags_per_row.iter().any( | f | !f.is_empty() );
let cols = resolve_columns( config );
let flags_insert_pos : Option< usize > = if any_flags
{
cols.iter().position( | &k | k == "state" ).map( | p | p + 1 )
}
else
{
None
};
let mut headers : Vec< String > = cols.iter().map( |k|
{
COLUMN_KEYS.iter()
.find( | ( ck, _ ) | ck == k )
.map_or_else( || ( *k ).to_string(), | ( _, h ) | ( *h ).to_string() )
} ).collect();
if let Some( p ) = flags_insert_pos { headers.insert( p, "Flags".to_string() ); }
let mut builder = RowBuilder::new( headers );
for ( ( idx, proc ), flags_str ) in sorted.iter().enumerate().zip( flags_per_row.iter() )
{
let mut row = build_row( idx + 1, proc, &cols );
if let Some( p ) = flags_insert_pos { row.insert( p, flags_str.clone() ); }
builder = builder.add_row( row.into_iter().map( Into::into ).collect() );
}
let caption = TableCaption::new( "Active Sessions" )
.field( format!( "{} running", sorted.len() ) );
let table_str = render_plain_table( builder, caption );
#[ cfg( target_os = "linux" ) ]
let legend = if any_flags { Some( build_legend( &flags_per_row ) ) } else { None };
#[ cfg( not( target_os = "linux" ) ) ]
let legend : Option< String > = None;
Some( ( table_str, legend ) )
}
fn build_row( idx : usize, proc : &ProcessInfo, cols : &[ &str ] ) -> Vec< String >
{
let pid = proc.pid;
#[ cfg( target_os = "linux" ) ]
let ( elapsed, cpu, ram, state ) =
{
use claude_core::process::read_process_metrics;
match read_process_metrics( pid )
{
Some( m ) => (
elapsed_label( m.started_at ),
format!( "{:.1}%", m.cpu_pct ),
ram_label( m.ram_kb ),
m.state.to_string(),
),
None => ( "-".to_string(), "-".to_string(), "-".to_string(), "-".to_string() ),
}
};
#[ cfg( not( target_os = "linux" ) ) ]
let ( elapsed, cpu, ram, state ) =
( "-".to_string(), "-".to_string(), "-".to_string(), "-".to_string() );
let path = shorten_path( &proc.cwd.display().to_string() );
let task = resolve_task( proc );
let mode = classify_mode( &proc.args ).to_string();
let command = proc.args.get( 1.. ).unwrap_or( &[] ).join( " " );
let binary = proc.args.first().cloned().unwrap_or_default();
cols.iter().map( |col| match *col
{
"idx" => idx.to_string(),
"pid" => pid.to_string(),
"elapsed" => elapsed.clone(),
"cpu" => cpu.clone(),
"ram" => ram.clone(),
"state" => state.clone(),
"path" => path.clone(),
"task" => task.clone(),
"mode" => mode.clone(),
"cmd" => command.clone(),
"binary" => binary.clone(),
_ => String::new(),
} ).collect()
}
fn shorten_path( path : &str ) -> String
{
if let Ok( pro ) = std::env::var( "PRO" )
{
if !pro.is_empty() && path.starts_with( pro.as_str() )
{
let rest = &path[ pro.len().. ];
return format!( "$PRO{rest}" );
}
}
path.to_string()
}
fn elapsed_label( started_at : u64 ) -> String
{
let elapsed = super::gate::unix_now().saturating_sub( started_at );
if elapsed < 60
{
format!( "{elapsed}s" )
}
else if elapsed < 3_600
{
let m = elapsed / 60;
let s = elapsed % 60;
format!( "{m}m {s}s" )
}
else
{
let h = elapsed / 3_600;
let m = ( elapsed % 3_600 ) / 60;
format!( "{h}h {m}m" )
}
}
fn ram_label( kb : u64 ) -> String
{
if kb >= 1_024 { format!( "{}M", kb / 1_024 ) }
else { format!( "{kb}K" ) }
}
fn resolve_task( proc : &ProcessInfo ) -> String
{
try_jsonl_task( proc ).unwrap_or_else( || "interactive".to_string() )
}
fn try_jsonl_task( proc : &ProcessInfo ) -> Option< String >
{
let home = std::env::var( "HOME" ).ok()?;
let cwd_str = proc.cwd.to_str()?;
let encoded = cwd_str.replace( [ '/', '_' ], "-" );
let dir = std::path::Path::new( &home )
.join( ".claude" )
.join( "projects" )
.join( &encoded );
let jsonl_path = std::fs::read_dir( &dir )
.ok()?
.flatten()
.filter( | e |
{
e.path().extension().and_then( | x | x.to_str() ) == Some( "jsonl" )
} )
.max_by_key( | e |
{
e.metadata().and_then( | m | m.modified() ).ok()
} )?
.path();
let content = std::fs::read_to_string( jsonl_path ).ok()?;
let last_user = content.lines().rev()
.find( | l |
l.contains( r#""type":"user""# )
&& l.contains( r#""content":""# )
&& !l.contains( r#""content":["# )
)?;
let marker = r#""content":""#;
let text_start = last_user.find( marker ).map( | i | i + marker.len() )?;
let rest = &last_user[ text_start .. ];
let text_end = rest.find( '"' )?;
let text = &rest[ .. text_end ];
let truncated : String = text.chars().take( 35 ).collect();
if truncated.is_empty() { return None; }
Some( truncated )
}
fn parse_json_str( content : &str, key : &str ) -> Option< String >
{
let marker = format!( r#""{key}":""# );
let start = content.find( marker.as_str() )? + marker.len();
let rest = &content[ start.. ];
let end = rest.find( '"' )?;
Some( rest[ ..end ].to_string() )
}
fn parse_json_u64( content : &str, key : &str ) -> Option< u64 >
{
let marker = format!( r#""{key}":"# );
let start = content.find( marker.as_str() )? + marker.len();
let rest = &content[ start.. ];
let end = rest.find( [ ',', '}' ] )?;
rest[ ..end ].trim().parse().ok()
}
fn build_queued_table() -> Option< String >
{
let dir = super::gate::gate_dir();
let mut entries : Vec< _ > = std::fs::read_dir( &dir )
.ok()?
.flatten()
.filter( |e|
{
if e.path().extension().and_then( |x| x.to_str() ) != Some( "json" )
{
return false;
}
let alive = e.path()
.file_stem()
.and_then( |s| s.to_str() )
.and_then( |s| s.parse::< u32 >().ok() )
.is_some_and( |pid|
{
std::path::Path::new( &format!( "/proc/{pid}" ) ).exists()
} );
if !alive
{
let _ = std::fs::remove_file( e.path() );
}
alive
} )
.collect();
if entries.is_empty() { return None; }
let count = entries.len();
entries.sort_by_key( |e|
{
e.path()
.file_stem()
.and_then( |s| s.to_str() )
.and_then( |s| s.parse::< u32 >().ok() )
.unwrap_or( u32::MAX )
} );
let headers = vec![
"#".to_string(),
"PID".to_string(),
"CWD".to_string(),
"Waiting".to_string(),
"Attempt".to_string(),
];
let mut builder = RowBuilder::new( headers );
for ( idx, entry ) in entries.iter().enumerate()
{
let path = entry.path();
let pid_str = path
.file_stem()
.and_then( |s| s.to_str() )
.unwrap_or( "?" )
.to_string();
let content = std::fs::read_to_string( &path ).unwrap_or_default();
let cwd = parse_json_str( &content, "cwd" ).unwrap_or_default();
let since = parse_json_u64( &content, "since" ).unwrap_or( 0 );
let attempt = parse_json_u64( &content, "attempt" ).unwrap_or( 0 );
let row = vec![
( idx + 1 ).to_string(),
pid_str,
shorten_path( &cwd ),
elapsed_label( since ),
attempt.to_string(),
];
builder = builder.add_row( row.into_iter().map( Into::into ).collect() );
}
let caption = TableCaption::new( "Queued" )
.field( format!( "{count} waiting" ) );
Some( render_plain_table( builder, caption ) )
}