use std::io;
use std::time::Duration;
use crate::Cli;
use crate::ViewFilters;
use crate::app::{App, FocusFilter, StatusFilter};
use crate::brain;
use crate::config;
use crate::demo;
use crate::discovery;
use crate::launch;
use crate::process;
use crate::rules;
use crate::session;
pub(crate) fn launch_session(
cwd: &str,
prompt: Option<&str>,
resume: Option<&str>,
) -> io::Result<()> {
let request = launch::prepare(cwd, prompt, resume).map_err(io::Error::other)?;
match launch::launch(&request) {
Ok(target) => {
println!(
"Launched Claude session in {} at {}{}",
target,
request.cwd_path.display(),
request.option_summary()
);
Ok(())
}
Err(e) => Err(io::Error::other(e)),
}
}
fn print_doctor_transcripts() {
println!();
println!("Transcript Discovery");
let sessions_dir = discovery::projects_dir().parent().unwrap().join("sessions");
let projects_dir = discovery::projects_dir();
let sessions_exists = sessions_dir.exists();
println!(
" [{}] sessions dir: {}",
if sessions_exists { "ok" } else { "!!" },
sessions_dir.display()
);
let projects_exists = projects_dir.exists();
println!(
" [{}] projects dir: {}",
if projects_exists { "ok" } else { "!!" },
projects_dir.display()
);
if !sessions_exists {
println!(" No session pointer files found — Claude Code may not have run yet");
return;
}
let mut sessions = discovery::scan_sessions();
if sessions.is_empty() {
println!(" [--] no session pointer files found");
return;
}
process::fetch_and_enrich(&mut sessions);
let alive: Vec<_> = sessions
.iter()
.filter(|s| s.status != session::SessionStatus::Finished)
.collect();
if alive.is_empty() {
println!(" [--] no active Claude Code sessions");
return;
}
let mut alive_sessions: Vec<_> = alive.into_iter().cloned().collect();
for s in &mut alive_sessions {
discovery::resolve_jsonl_paths(std::slice::from_mut(s));
}
for s in &alive_sessions {
let found = s.jsonl_path.is_some();
let slug = s.cwd.trim_end_matches('/').replace('/', "-");
let expected_dir = projects_dir.join(&slug);
println!(
" [{}] PID {} ({})",
if found { "ok" } else { "!!" },
s.pid,
s.project_name
);
println!(" cwd: {}", s.cwd);
println!(" slug: {slug}");
if let Some(ref path) = s.jsonl_path {
println!(" jsonl: {}", path.display());
} else {
println!(
" expected dir: {} (exists={})",
expected_dir.display(),
expected_dir.exists()
);
let expected_file = expected_dir.join(format!("{}.jsonl", s.session_id));
println!(
" expected file: {} (exists={})",
expected_file.display(),
expected_file.exists()
);
println!(
" fix: check that Claude Code's project directory slug matches the cwd encoding above"
);
}
}
}
pub(crate) fn print_doctor() -> io::Result<()> {
use crate::terminals;
let report = terminals::doctor_report();
println!("{}", terminals::format_doctor_report(&report));
print_doctor_transcripts();
let cfg = config::Config::load();
println!();
println!("Brain (local LLM)");
let curl_ok = std::process::Command::new("curl")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success());
println!(
" [{}] curl: {}",
if curl_ok { "ok" } else { "!!" },
if curl_ok {
"available (required for brain HTTP calls)"
} else {
"not found — brain requires curl on PATH"
}
);
let ollama_ok = std::process::Command::new("ollama")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success());
println!(
" [{}] ollama: {}",
if ollama_ok { "ok" } else { "--" },
if ollama_ok {
"installed"
} else {
"not found (install: brew install ollama)"
}
);
if let Some(ref brain) = cfg.brain {
println!(
" Config: enabled={}, model={}, auto={}, few_shot={}",
brain.enabled, brain.model, brain.auto_mode, brain.few_shot_count
);
let endpoint_ok = check_brain_endpoint(&brain.endpoint, brain.timeout_ms);
println!(
" [{}] endpoint {}: {}",
if endpoint_ok { "ok" } else { "!!" },
brain.endpoint,
if endpoint_ok {
"reachable"
} else {
"not reachable"
}
);
if !endpoint_ok {
println!(" fix: start ollama with `ollama serve`, or check --brain-endpoint URL");
}
} else {
println!(" Config: not configured");
println!(" To enable: add [brain] section to .claudectl.toml or use --brain flag");
}
Ok(())
}
pub(crate) fn check_brain_endpoint(endpoint: &str, timeout_ms: u64) -> bool {
let timeout_secs = (timeout_ms / 1000).max(1);
std::process::Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
&timeout_secs.to_string(),
endpoint,
])
.output()
.is_ok_and(|o| {
let code = String::from_utf8_lossy(&o.stdout);
code.trim() != "000"
})
}
pub(crate) fn parse_duration_str(s: &str) -> Duration {
let s = s.trim();
if let Some(hours) = s.strip_suffix('h') {
if let Ok(h) = hours.parse::<u64>() {
return Duration::from_secs(h * 3600);
}
}
if let Some(mins) = s.strip_suffix('m') {
if let Ok(m) = mins.parse::<u64>() {
return Duration::from_secs(m * 60);
}
}
if let Some(days) = s.strip_suffix('d') {
if let Ok(d) = days.parse::<u64>() {
return Duration::from_secs(d * 86400);
}
}
Duration::from_secs(24 * 3600) }
pub(crate) fn parse_status_filter(value: Option<&str>) -> io::Result<StatusFilter> {
match value {
Some(raw) => StatusFilter::parse(raw).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"Invalid --filter-status value: {raw}. Expected one of: all, needs-input, processing, waiting, unknown, idle, finished"
),
)
}),
None => Ok(StatusFilter::All),
}
}
pub(crate) fn parse_focus_filter(value: Option<&str>) -> io::Result<FocusFilter> {
match value {
Some(raw) => FocusFilter::parse(raw).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"Invalid --focus value: {raw}. Expected one of: all, attention, over-budget, high-context, unknown-telemetry, conflict"
),
)
}),
None => Ok(FocusFilter::All),
}
}
pub(crate) fn apply_filters(app: &mut App, filters: &ViewFilters) {
app.status_filter = filters.status_filter;
app.focus_filter = filters.focus_filter;
app.search_query = filters.search.trim().to_string();
app.search_buffer.clear();
app.search_mode = false;
let len = app.visible_session_count();
if len == 0 {
app.table_state.select(None);
} else if app.table_state.selected().is_none() {
app.table_state.select(Some(0));
} else if let Some(sel) = app.table_state.selected() {
if sel >= len {
app.table_state.select(Some(len - 1));
}
}
}
pub(crate) fn run_autopsy(session_arg: Option<&str>, json_output: bool) -> io::Result<()> {
let jsonl_path = resolve_jsonl_for_autopsy(session_arg)?;
eprintln!("Analyzing: {}", jsonl_path.display());
let mut report = brain::autopsy::run_autopsy(&jsonl_path).map_err(io::Error::other)?;
if let Some(parent) = jsonl_path.parent() {
if let Some(name) = parent.file_name().and_then(|n| n.to_str()) {
report.project = name.to_string();
}
}
if json_output {
let json = brain::autopsy::report_to_json(&report);
println!(
"{}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
} else {
print!("{}", brain::autopsy::format_report(&report));
}
match brain::autopsy::save_report(&report) {
Ok(path) => eprintln!("Saved: {}", path.display()),
Err(e) => eprintln!("Warning: could not save autopsy report: {e}"),
}
Ok(())
}
fn resolve_jsonl_for_autopsy(session_arg: Option<&str>) -> io::Result<std::path::PathBuf> {
if let Some(arg) = session_arg {
let path = std::path::PathBuf::from(arg);
if arg.ends_with(".jsonl") && path.exists() {
return Ok(path);
}
let projects_dir = discovery::projects_dir();
if projects_dir.is_dir() {
if let Ok(entries) = std::fs::read_dir(&projects_dir) {
for entry in entries.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let candidate = dir.join(format!("{arg}.jsonl"));
if candidate.exists() {
return Ok(candidate);
}
}
}
}
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Session not found: {arg}"),
));
}
find_most_recent_jsonl()
}
fn find_most_recent_jsonl() -> io::Result<std::path::PathBuf> {
let projects_dir = discovery::projects_dir();
let mut best: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
if projects_dir.is_dir() {
if let Ok(project_entries) = std::fs::read_dir(&projects_dir) {
for project_entry in project_entries.flatten() {
let dir = project_entry.path();
if !dir.is_dir() {
continue;
}
if let Ok(files) = std::fs::read_dir(&dir) {
for file_entry in files.flatten() {
let path = file_entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if let Ok(meta) = file_entry.metadata() {
if let Ok(modified) = meta.modified() {
let dominated = best.as_ref().is_none_or(|(_, t)| modified > *t);
if dominated {
best = Some((path, modified));
}
}
}
}
}
}
}
}
best.map(|(p, _)| p).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"No JSONL transcripts found. Run some Claude Code sessions first.",
)
})
}
pub(crate) fn run_clean(
older_than: Option<&str>,
finished_only: bool,
dry_run: bool,
) -> io::Result<()> {
let min_age = older_than.map(parse_duration_str);
let now = std::time::SystemTime::now();
let home = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let active_pids: std::collections::HashSet<u32> = {
let app = App::new();
app.sessions.iter().map(|s| s.pid).collect()
};
let mut removed_sessions = 0u64;
let mut removed_jsonl = 0u64;
let mut freed_bytes = 0u64;
let sessions_dir = home.join(".claude/sessions");
if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let pid: u32 = match stem.parse() {
Ok(p) => p,
Err(_) => continue,
};
if active_pids.contains(&pid) {
continue;
}
if let Some(min_age) = min_age {
let modified = entry.metadata().ok().and_then(|m| m.modified().ok());
if let Some(modified) = modified {
let age = now.duration_since(modified).unwrap_or_default();
if age < min_age {
continue;
}
}
}
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
if dry_run {
println!(" would remove: {} ({} bytes)", path.display(), size);
} else {
let _ = std::fs::remove_file(&path);
}
removed_sessions += 1;
freed_bytes += size;
}
}
let projects_dir = home.join(".claude/projects");
if let Ok(project_entries) = std::fs::read_dir(&projects_dir) {
for project_entry in project_entries.flatten() {
let project_path = project_entry.path();
if !project_path.is_dir() {
continue;
}
let Ok(files) = std::fs::read_dir(&project_path) else {
continue;
};
for file_entry in files.flatten() {
let file_path = file_entry.path();
if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let metadata = match file_entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if let Some(min_age) = min_age {
let modified = metadata.modified().ok();
if let Some(modified) = modified {
let age = now.duration_since(modified).unwrap_or_default();
if age < min_age {
continue;
}
}
}
if finished_only {
let app = App::new();
let is_active = app.sessions.iter().any(|s| {
s.jsonl_path
.as_ref()
.map(|p| p == &file_path)
.unwrap_or(false)
});
if is_active {
continue;
}
}
let size = metadata.len();
if dry_run {
println!(" would remove: {} ({} bytes)", file_path.display(), size);
} else {
let _ = std::fs::remove_file(&file_path);
}
removed_jsonl += 1;
freed_bytes += size;
}
}
}
let freed_str = if freed_bytes >= 1_073_741_824 {
format!("{:.1} GB", freed_bytes as f64 / 1_073_741_824.0)
} else if freed_bytes >= 1_048_576 {
format!("{:.1} MB", freed_bytes as f64 / 1_048_576.0)
} else if freed_bytes >= 1024 {
format!("{:.1} KB", freed_bytes as f64 / 1024.0)
} else {
format!("{freed_bytes} bytes")
};
if dry_run {
println!();
println!(
"Dry run: would remove {} sessions + {} transcripts, freeing {}",
removed_sessions, removed_jsonl, freed_str
);
} else if removed_sessions + removed_jsonl == 0 {
println!("Nothing to clean up.");
} else {
println!(
"Removed {} sessions + {} transcripts, freed {}",
removed_sessions, removed_jsonl, freed_str
);
}
Ok(())
}
pub(crate) fn print_summary(since: &str) -> io::Result<()> {
let since_duration = parse_duration_str(since);
let app = App::new();
if app.sessions.is_empty() {
println!("No active Claude sessions.");
return Ok(());
}
for s in &app.sessions {
let status_color = match s.status {
session::SessionStatus::Processing => "\x1b[32m",
session::SessionStatus::NeedsInput => "\x1b[35m",
session::SessionStatus::WaitingInput => "\x1b[33m",
session::SessionStatus::Unknown => "\x1b[34m",
session::SessionStatus::Idle => "\x1b[90m",
session::SessionStatus::Finished => "\x1b[31m",
};
let reset = "\x1b[0m";
let status_text = if s.status == session::SessionStatus::Unknown {
format!("Unknown: {}", s.telemetry_label())
} else {
s.status.to_string()
};
println!(
"=== {} ({}, {}, {status_color}{}{reset}) ===",
s.display_name(),
s.format_elapsed(),
s.format_cost(),
status_text,
);
let since_secs = since_duration.as_secs();
let git_since = format!("{since_secs} seconds ago");
let git_log = std::process::Command::new("git")
.args(["log", "--oneline", &format!("--since={git_since}")])
.current_dir(&s.cwd)
.output();
if let Ok(output) = git_log {
let stdout = String::from_utf8_lossy(&output.stdout);
let commits: Vec<&str> = stdout.lines().collect();
if !commits.is_empty() {
println!(" Commits: {}", commits.len());
for c in commits.iter().take(5) {
println!(" {c}");
}
if commits.len() > 5 {
println!(" ... and {} more", commits.len() - 5);
}
}
}
let git_diff = std::process::Command::new("git")
.args(["diff", "--stat", "HEAD"])
.current_dir(&s.cwd)
.output();
if let Ok(output) = git_diff {
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
if !lines.is_empty() {
let file_count = lines.len().saturating_sub(1); if file_count > 0 {
println!(" Files changed: {file_count}");
}
}
}
let total_tokens = s.total_input_tokens + s.total_output_tokens;
if total_tokens > 0 {
println!(
" Tokens: {} in / {} out",
format_count(s.total_input_tokens),
format_count(s.total_output_tokens)
);
}
if !s.model.is_empty() {
let context_text = if s.has_usage_metrics() {
format!("{}%", s.context_percent() as u32)
} else {
"n/a".to_string()
};
let estimate_note = if s.cost_estimate_unverified {
" [fallback estimate]"
} else if s.model_profile_source == "override" {
" [config override]"
} else {
""
};
println!(
" Model: {}{} (context: {})",
s.model, estimate_note, context_text
);
}
if s.status == session::SessionStatus::Unknown || !s.has_usage_metrics() {
println!(" Telemetry: {}", s.telemetry_label());
}
if s.subagent_count > 0 {
println!(" Subagents: {}", s.format_subagent_summary());
}
println!();
}
let total_cost: f64 = app.sessions.iter().map(|s| s.cost_usd).sum();
println!("Total cost: ${total_cost:.2}");
Ok(())
}
pub(crate) fn format_count(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
fn make_app(demo: bool, filters: &ViewFilters) -> App {
let mut app = if demo {
let mut app = App::new();
app.demo_mode = true;
app.sessions = demo::generate_sessions(10);
app
} else {
App::new()
};
apply_filters(&mut app, filters);
app
}
pub(crate) fn print_json(demo: bool, filters: &ViewFilters) -> io::Result<()> {
let app = make_app(demo, filters);
let values: Vec<serde_json::Value> = app
.visible_sessions()
.iter()
.map(|s| s.to_json_value())
.collect();
let json = serde_json::to_string_pretty(&values).unwrap_or_else(|_| "[]".to_string());
println!("{json}");
Ok(())
}
pub(crate) fn print_list(demo: bool, filters: &ViewFilters) -> io::Result<()> {
let app = make_app(demo, filters);
let visible_sessions = app.visible_sessions();
if visible_sessions.is_empty() {
if app.has_active_filters() {
println!("No sessions match the current filters.");
} else {
println!("No active Claude sessions.");
}
if app.has_active_filters() {
println!(" ({})", app.filter_summary());
}
return Ok(());
}
println!(
"{:<7} {:<16} {:<12} {:<8} {:<8} {:<9} {:<10} {:<6} {:<6} TOKENS",
"PID", "PROJECT", "STATUS", "CTX%", "COST", "$/HR", "ELAPSED", "CPU%", "MEM"
);
println!("{}", "-".repeat(105));
for s in visible_sessions {
let status_text = if s.status == session::SessionStatus::Unknown {
s.telemetry_status.short_label().to_string()
} else {
s.status.to_string()
};
println!(
"{:<7} {:<16} {:<12} {:<8} {:<8} {:<9} {:<10} {:<6.1} {:<6} {}",
s.pid,
s.display_name(),
status_text,
s.format_context(),
s.format_cost(),
s.format_burn_rate(),
s.format_elapsed(),
s.cpu_percent,
s.format_mem(),
s.format_tokens(),
);
}
let total_cost: f64 = app.visible_sessions().iter().map(|s| s.cost_usd).sum();
println!("{}", "-".repeat(105));
println!("Total cost: ${total_cost:.2}");
if app.has_active_filters() {
println!("{}", app.filter_summary());
}
Ok(())
}
pub(crate) fn run_watch(
tick_rate: Duration,
json_mode: bool,
format_str: &str,
filters: &ViewFilters,
) -> io::Result<()> {
use crate::session::SessionStatus;
use std::collections::HashMap;
let mut app = App::new();
apply_filters(&mut app, filters);
let mut prev_statuses: HashMap<u32, SessionStatus> =
app.sessions.iter().map(|s| (s.pid, s.status)).collect();
for s in app.visible_sessions() {
if json_mode {
let obj = serde_json::json!({
"event": "initial",
"pid": s.pid,
"project": s.display_name(),
"status": s.status.to_string(),
"telemetry": s.telemetry_label(),
"cost_usd": if s.has_usage_metrics() { serde_json::json!((s.cost_usd * 100.0).round() / 100.0) } else { serde_json::Value::Null },
"context_pct": if s.has_usage_metrics() { serde_json::json!((s.context_percent() * 100.0).round() / 100.0) } else { serde_json::Value::Null },
"elapsed_secs": s.elapsed.as_secs(),
});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
println!("{}", format_session(format_str, s));
}
}
loop {
std::thread::sleep(tick_rate);
app.tick();
let visible_pids: std::collections::HashSet<u32> =
app.visible_sessions().iter().map(|s| s.pid).collect();
for s in &app.sessions {
let prev = prev_statuses.get(&s.pid).copied();
let changed = prev.is_none_or(|p| p != s.status);
if !changed || !visible_pids.contains(&s.pid) {
continue;
}
if json_mode {
let obj = serde_json::json!({
"event": "status_change",
"pid": s.pid,
"project": s.display_name(),
"old_status": prev.map(|p| p.to_string()).unwrap_or_default(),
"new_status": s.status.to_string(),
"telemetry": s.telemetry_label(),
"cost_usd": if s.has_usage_metrics() { serde_json::json!((s.cost_usd * 100.0).round() / 100.0) } else { serde_json::Value::Null },
"context_pct": if s.has_usage_metrics() { serde_json::json!((s.context_percent() * 100.0).round() / 100.0) } else { serde_json::Value::Null },
"elapsed_secs": s.elapsed.as_secs(),
});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
println!("{}", format_session(format_str, s));
}
}
prev_statuses = app.sessions.iter().map(|s| (s.pid, s.status)).collect();
}
}
pub(crate) fn run_headless(
tick_rate: Duration,
cfg: &crate::config::Config,
json_mode: bool,
) -> io::Result<()> {
use crate::session::SessionStatus;
use std::collections::HashMap;
let mut app = App::new();
app.hooks = crate::config::load_hooks();
app.rules = cfg.rules.clone();
app.health_thresholds = cfg.health.clone();
app.file_conflicts_enabled = cfg.file_conflicts;
app.auto_deny_file_conflicts = cfg.auto_deny_file_conflicts;
app.idle_config = cfg.idle.clone();
app.brain_config = cfg.brain.clone();
app.budget_usd = cfg.budget;
app.kill_on_budget = cfg.kill_on_budget;
app.notify = cfg.notify;
app.context_warn_threshold = cfg.context_warn_threshold;
app.daily_limit = cfg.daily_limit;
app.weekly_limit = cfg.weekly_limit;
if let Some(ref brain_cfg) = cfg.brain {
if brain_cfg.enabled {
if check_brain_endpoint(&brain_cfg.endpoint, brain_cfg.timeout_ms) {
app.brain_engine = Some(crate::brain::engine::BrainEngine::new(brain_cfg.clone()));
emit_headless_event(
"startup",
serde_json::json!({
"brain": true,
"endpoint": brain_cfg.endpoint,
"model": brain_cfg.model,
"auto_mode": brain_cfg.auto_mode,
}),
json_mode,
);
} else {
eprintln!(
"Warning: brain endpoint {} not reachable -- running without brain",
brain_cfg.endpoint
);
emit_headless_event(
"startup",
serde_json::json!({"brain": false, "reason": "endpoint not reachable"}),
json_mode,
);
}
}
} else {
emit_headless_event(
"startup",
serde_json::json!({"brain": false, "reason": "not configured"}),
json_mode,
);
}
emit_headless_event(
"startup",
serde_json::json!({
"rules": app.rules.len(),
"sessions": app.sessions.len(),
"interval_ms": tick_rate.as_millis(),
}),
json_mode,
);
let mut prev_statuses: HashMap<u32, SessionStatus> =
app.sessions.iter().map(|s| (s.pid, s.status)).collect();
#[cfg(feature = "coord")]
let mut tick_count: u64 = 0;
loop {
std::thread::sleep(tick_rate);
app.tick();
#[cfg(feature = "coord")]
{
tick_count += 1;
}
for s in &app.sessions {
let prev = prev_statuses.get(&s.pid).copied();
let changed = prev.is_none_or(|p| p != s.status);
if changed {
emit_headless_event(
"status_change",
serde_json::json!({
"pid": s.pid,
"project": s.display_name(),
"old_status": prev.map(|p| p.to_string()),
"new_status": s.status.to_string(),
"cost_usd": s.cost_usd,
"context_pct": s.context_percent(),
"decay_score": s.decay_score,
}),
json_mode,
);
}
}
if !app.status_msg.is_empty()
&& (app.status_msg.starts_with("Brain:")
|| app.status_msg.starts_with("Interrupt")
|| app.status_msg.starts_with("MAILBOX"))
{
emit_headless_event(
"action",
serde_json::json!({"detail": app.status_msg}),
json_mode,
);
}
#[cfg(feature = "coord")]
check_context_rot(&app, json_mode);
#[cfg(feature = "coord")]
if tick_count % 15 == 0 {
emit_headless_event(
"coord_summary",
serde_json::json!({
"active_leases": app.coord_leases.len(),
"pending_handoffs": app.coord_handoffs.len(),
"pending_interrupts": app.coord_pending_interrupts.len(),
"sessions": app.sessions.len(),
}),
json_mode,
);
}
#[cfg(feature = "coord")]
if tick_count % 1800 == 0 && tick_count > 0 {
if let Ok(conn) = crate::coord::store::open() {
if let Ok(pruned) = crate::coord::store::prune(&conn, None) {
if pruned > 0 {
emit_headless_event(
"pruned",
serde_json::json!({"rows_deleted": pruned}),
json_mode,
);
}
}
}
}
prev_statuses = app.sessions.iter().map(|s| (s.pid, s.status)).collect();
}
}
fn emit_headless_event(event: &str, data: serde_json::Value, json_mode: bool) {
let ts = crate::logger::timestamp_now();
if json_mode {
let obj = serde_json::json!({"ts": ts, "event": event, "data": data});
println!("{}", serde_json::to_string(&obj).unwrap_or_default());
} else {
let detail = if let Some(obj) = data.as_object() {
obj.iter()
.map(|(k, v)| {
let val = match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
format!("{k}={val}")
})
.collect::<Vec<_>>()
.join(" ")
} else {
data.to_string()
};
println!("[{ts}] {event}: {detail}");
}
}
#[cfg(feature = "coord")]
fn check_context_rot(app: &App, json_mode: bool) {
let conn = match crate::coord::store::open() {
Ok(c) => c,
Err(_) => return,
};
for session in &app.sessions {
if session.decay_score == 0 || !session.has_usage_metrics() {
continue;
}
let (interrupt_type, priority) = if session.decay_score >= 85 {
(
crate::coord::types::InterruptType::Stop,
"critical".to_string(),
)
} else if session.decay_score as f64 >= app.health_thresholds.decay_compaction_pct {
(
crate::coord::types::InterruptType::Compact,
"high".to_string(),
)
} else {
continue;
};
let dedupe_key = format!("decay:{}:{}", interrupt_type.as_str(), session.session_id);
if let Ok(Some(_)) = crate::coord::store::find_duplicate_interrupt(&conn, &dedupe_key) {
continue;
}
let reason = format!(
"Context rot detected: decay_score={}/100, context={}%",
session.decay_score,
session.context_percent() as u32
);
let intr = crate::coord::types::Interrupt {
id: crate::coord::store::gen_id("intr"),
interrupt_type,
priority: priority.clone(),
target_session_id: session.session_id.clone(),
reason: reason.clone(),
payload: Some(serde_json::json!({
"decay_score": session.decay_score,
"context_pct": session.context_percent(),
"pid": session.pid,
})),
delivery_mode: "safe_boundary".into(),
max_retries: 3,
retry_count: 0,
next_retry_at: None,
expires_at: None,
dedupe_key: Some(dedupe_key),
state: crate::coord::types::InterruptState::Pending,
created_at: crate::logger::timestamp_now(),
delivered_at: None,
acknowledged_at: None,
};
let _ = crate::coord::store::insert_interrupt(&conn, &intr);
let _ = crate::coord::store::append_event(
&conn,
&crate::coord::types::CoordEvent {
id: None,
event_type: crate::coord::types::EventType::InterruptRaised,
timestamp: crate::logger::timestamp_now(),
session_id: Some(session.session_id.clone()),
payload: serde_json::json!({
"interrupt_id": intr.id,
"type": interrupt_type.as_str(),
"decay_score": session.decay_score,
}),
},
);
emit_headless_event(
"context_rot",
serde_json::json!({
"pid": session.pid,
"project": session.display_name(),
"decay_score": session.decay_score,
"action": interrupt_type.as_str(),
"priority": priority,
}),
json_mode,
);
}
}
pub(crate) fn format_session(fmt: &str, s: &session::ClaudeSession) -> String {
let cost = if s.has_usage_metrics() {
format!("{:.2}", s.cost_usd)
} else {
"n/a".to_string()
};
let context = if s.has_usage_metrics() {
format!("{}", s.context_percent() as u32)
} else {
"n/a".to_string()
};
fmt.replace("{pid}", &s.pid.to_string())
.replace("{project}", s.display_name())
.replace("{status}", &s.status.to_string())
.replace("{cost}", &cost)
.replace("{context}", &context)
}
pub(crate) fn brain_gate_mode_path() -> std::path::PathBuf {
claudectl::brain::gate_mode_path()
}
pub(crate) fn read_brain_gate_mode() -> String {
claudectl::brain::read_gate_mode()
}
pub(crate) fn run_brain_mode(mode: &str) -> io::Result<()> {
match mode {
"on" | "off" | "auto" => {}
"status" | "" => {
let current = read_brain_gate_mode();
println!("Brain gate mode: {current}");
println!();
println!("Modes:");
println!(" on — brain evaluates tool calls, denies dangerous ones (default)");
println!(" off — brain disabled, all tool calls pass through");
println!(" auto — brain auto-approves above confidence threshold");
return Ok(());
}
_ => {
eprintln!("Unknown brain mode: {mode}");
eprintln!("Valid modes: on, off, auto, status");
std::process::exit(1);
}
}
let path = brain_gate_mode_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if mode == "on" {
let _ = std::fs::remove_file(&path);
} else {
std::fs::write(&path, mode)?;
}
let description = match mode {
"on" => "brain evaluates tool calls, denies dangerous ones",
"off" => "brain disabled — all tool calls pass through to normal permission flow",
"auto" => "brain auto-approves tool calls above confidence threshold",
_ => unreachable!(),
};
println!("Brain gate mode set to: {mode}");
println!(" {description}");
Ok(())
}
pub(crate) fn run_insights(cfg: &config::Config, cli: &Cli, arg: &str) -> io::Result<()> {
let brain_enabled = cfg.brain.as_ref().map(|b| b.enabled).unwrap_or(false) || cli.brain;
if !brain_enabled {
eprintln!(
"Insights requires the brain. Use --brain or set brain.enabled = true in config."
);
std::process::exit(1);
}
match arg {
"on" => {
let _ = brain::insights::write_insights_mode("on");
println!("Insights mode: on");
println!(" Auto-generating insights every 10 decisions during brain distillation.");
println!(" Run `claudectl --brain --insights` to view.");
}
"off" => {
let _ = brain::insights::write_insights_mode("off");
println!("Insights mode: off");
println!(
" Auto-generation disabled. Run `claudectl --brain --insights` to generate on demand."
);
}
"status" => {
let mode = brain::insights::read_insights_mode();
println!("Insights mode: {mode}");
println!();
println!("Modes:");
println!(" on — auto-generate insights every 10 decisions");
println!(" off — disabled, generate on demand only (default)");
}
"" => {
brain::insights::print_insights();
}
_ => {
eprintln!("Unknown insights argument: {arg}");
eprintln!("Usage: --insights [on|off|status]");
eprintln!(" No argument: show current insights");
std::process::exit(1);
}
}
Ok(())
}
pub(crate) fn run_brain_query(cfg: &config::Config, cli: &Cli) -> io::Result<()> {
let gate_mode = read_brain_gate_mode();
if gate_mode == "off" {
let result = serde_json::json!({
"action": "abstain",
"reasoning": "Brain gate mode is off",
"confidence": 0.0,
"source": "gate",
});
println!("{}", serde_json::to_string(&result).unwrap());
return Ok(());
}
let brain_cfg = cfg.brain.clone().unwrap_or_default();
if !brain_cfg.enabled && !cli.brain {
eprintln!("Brain is not enabled. Use --brain or set brain.enabled = true in config.");
std::process::exit(1);
}
let tool_name = cli.tool.clone().unwrap_or_else(|| "unknown".into());
let command = cli.tool_input.clone().unwrap_or_default();
let project = cli.project.clone().unwrap_or_else(|| {
std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "unknown".into())
});
let auto_rules = cfg.rules.clone();
let deny_rules: Vec<_> = auto_rules
.iter()
.filter(|r| r.action == rules::RuleAction::Deny)
.cloned()
.collect();
let mut synthetic = session::ClaudeSession::from_raw(session::RawSession {
pid: std::process::id(),
session_id: "brain-query".into(),
cwd: std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| ".".into()),
started_at: 0,
});
synthetic.project_name = project.clone();
synthetic.status = session::SessionStatus::NeedsInput;
synthetic.pending_tool_name = Some(tool_name.clone());
synthetic.pending_tool_input = if command.is_empty() {
None
} else {
Some(command.clone())
};
if let Some(deny_match) = rules::evaluate(&deny_rules, &synthetic) {
let result = serde_json::json!({
"action": "deny",
"reasoning": format!("Deny rule '{}' matched", deny_match.rule_name),
"confidence": 1.0,
"source": "rule",
});
println!("{}", serde_json::to_string(&result).unwrap());
return Ok(());
}
let approve_rules: Vec<_> = auto_rules
.iter()
.filter(|r| r.action == rules::RuleAction::Approve)
.cloned()
.collect();
if let Some(approve_match) = rules::evaluate(&approve_rules, &synthetic) {
let result = serde_json::json!({
"action": "approve",
"reasoning": format!("Approve rule '{}' matched", approve_match.rule_name),
"confidence": 1.0,
"source": "rule",
});
println!("{}", serde_json::to_string(&result).unwrap());
return Ok(());
}
let tool_display = if command.is_empty() {
tool_name.clone()
} else {
format!("{tool_name}: {command}")
};
let session_summary = format!(
"Project: {project} | Status: Needs Input | Pending tool: {tool_name} | Command: {command}"
);
let pref_section = if let Some(prefs) = brain::decisions::load_preferences_for_project(&project)
{
let summary = brain::decisions::format_preference_summary(&prefs);
format!("\n\n## Learned Preferences\n{summary}")
} else {
String::new()
};
let few_shot_section = {
let similar = brain::decisions::retrieve_similar(
Some(&tool_name),
&project,
brain_cfg.few_shot_count.min(5),
Some(brain::decisions::DecisionType::Session),
);
if similar.is_empty() {
String::new()
} else {
let examples = brain::decisions::format_few_shot_examples(&similar);
format!("\n\n## Past Decisions\n{examples}")
}
};
let prompt = format!(
"You are a session supervisor deciding whether to approve or deny a tool call.\n\
\n## Session\n{session_summary}\
{pref_section}\
{few_shot_section}\n\
\n## Decision\n\
The session wants to run [{tool_display}]. \
Should this be approved or denied? \
Respond with JSON: {{\"action\": \"approve\"|\"deny\", \
\"message\": \"...\", \"reasoning\": \"...\", \"confidence\": 0.0-1.0}}"
);
match brain::client::infer(&brain_cfg, &prompt) {
Ok(suggestion) => {
let threshold = brain::decisions::adaptive_threshold(Some(&tool_name)).unwrap_or(0.6);
let below_threshold = suggestion.confidence < threshold;
let result = serde_json::json!({
"action": suggestion.action.label(),
"reasoning": suggestion.reasoning,
"confidence": suggestion.confidence,
"message": suggestion.message,
"source": "brain",
"below_threshold": below_threshold,
"threshold": threshold,
});
println!("{}", serde_json::to_string(&result).unwrap());
Ok(())
}
Err(e) => {
let result = serde_json::json!({
"action": "abstain",
"reasoning": format!("Brain query failed: {e}"),
"confidence": 0.0,
"source": "error",
});
println!("{}", serde_json::to_string(&result).unwrap());
Ok(())
}
}
}