diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9d9df4f7..32cd9668 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1961,6 +1961,7 @@ fn render_doctor_report() -> Result> { project_root, git_branch, git_summary, + session_lifecycle: classify_session_lifecycle_for(&cwd), sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), // Doctor path has its own config check; StatusContext here is only // fed into health renderers that don't read config_load_error. @@ -2805,6 +2806,7 @@ struct StatusContext { project_root: Option, git_branch: Option, git_summary: GitWorkspaceSummary, + session_lifecycle: SessionLifecycleSummary, sandbox_status: runtime::SandboxStatus, /// #143: when `.claw.json` (or another loaded config file) fails to parse, /// we capture the parse error here and still populate every field that @@ -2834,6 +2836,75 @@ struct GitWorkspaceSummary { conflicted_files: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SessionLifecycleKind { + RunningProcess, + IdleShell, + SavedOnly, +} + +impl SessionLifecycleKind { + fn as_str(self) -> &'static str { + match self { + Self::RunningProcess => "running_process", + Self::IdleShell => "idle_shell", + Self::SavedOnly => "saved_only", + } + } + + fn human_label(self) -> &'static str { + match self { + Self::RunningProcess => "running process", + Self::IdleShell => "idle shell", + Self::SavedOnly => "saved only", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SessionLifecycleSummary { + kind: SessionLifecycleKind, + pane_id: Option, + pane_command: Option, + pane_path: Option, + workspace_dirty: bool, + abandoned: bool, +} + +impl SessionLifecycleSummary { + fn signal(&self) -> String { + let mut parts = vec![self.kind.human_label().to_string()]; + if self.workspace_dirty { + parts.push("dirty worktree".to_string()); + } + if self.abandoned { + parts.push("abandoned?".to_string()); + } + if let Some(command) = self.pane_command.as_deref() { + parts.push(format!("cmd={command}")); + } + parts.join(" · ") + } + + fn json_value(&self) -> serde_json::Value { + json!({ + "kind": self.kind.as_str(), + "pane_id": self.pane_id, + "pane_command": self.pane_command, + "pane_path": self.pane_path.as_ref().map(|path| path.display().to_string()), + "workspace_dirty": self.workspace_dirty, + "abandoned": self.abandoned, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TmuxPaneSnapshot { + pane_id: String, + current_command: String, + current_path: PathBuf, +} + impl GitWorkspaceSummary { fn is_clean(self) -> bool { self.changed_files == 0 @@ -2865,6 +2936,120 @@ impl GitWorkspaceSummary { } } +fn classify_session_lifecycle_for(workspace: &Path) -> SessionLifecycleSummary { + classify_session_lifecycle_from_panes(workspace, discover_tmux_panes()) +} + +fn classify_session_lifecycle_from_panes( + workspace: &Path, + panes: Vec, +) -> SessionLifecycleSummary { + let workspace_dirty = git_worktree_is_dirty(workspace); + let mut idle_shell = None; + for pane in panes { + if !pane_path_matches_workspace(&pane.current_path, workspace) { + continue; + } + if is_idle_shell_command(&pane.current_command) { + idle_shell.get_or_insert(pane); + } else { + return SessionLifecycleSummary { + kind: SessionLifecycleKind::RunningProcess, + pane_id: Some(pane.pane_id), + pane_command: Some(pane.current_command), + pane_path: Some(pane.current_path), + workspace_dirty, + abandoned: false, + }; + } + } + + if let Some(pane) = idle_shell { + SessionLifecycleSummary { + kind: SessionLifecycleKind::IdleShell, + pane_id: Some(pane.pane_id), + pane_command: Some(pane.current_command), + pane_path: Some(pane.current_path), + workspace_dirty, + abandoned: workspace_dirty, + } + } else { + SessionLifecycleSummary { + kind: SessionLifecycleKind::SavedOnly, + pane_id: None, + pane_command: None, + pane_path: None, + workspace_dirty, + abandoned: workspace_dirty, + } + } +} + +fn discover_tmux_panes() -> Vec { + let output = Command::new("tmux") + .args([ + "list-panes", + "-a", + "-F", + "#{pane_id}\t#{pane_current_command}\t#{pane_current_path}", + ]) + .output(); + let Ok(output) = output else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + let stdout = String::from_utf8_lossy(&output.stdout); + parse_tmux_pane_snapshots(&stdout) +} + +fn parse_tmux_pane_snapshots(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + let mut fields = line.splitn(3, '\t'); + let pane_id = fields.next()?.trim(); + let current_command = fields.next()?.trim(); + let current_path = fields.next()?.trim(); + if pane_id.is_empty() || current_path.is_empty() { + return None; + } + Some(TmuxPaneSnapshot { + pane_id: pane_id.to_string(), + current_command: current_command.to_string(), + current_path: PathBuf::from(current_path), + }) + }) + .collect() +} + +fn pane_path_matches_workspace(pane_path: &Path, workspace: &Path) -> bool { + let pane_path = fs::canonicalize(pane_path).unwrap_or_else(|_| pane_path.to_path_buf()); + let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); + pane_path == workspace || pane_path.starts_with(&workspace) +} + +fn is_idle_shell_command(command: &str) -> bool { + let command = command.rsplit('/').next().unwrap_or(command); + matches!( + command, + "bash" | "zsh" | "sh" | "fish" | "nu" | "pwsh" | "powershell" | "cmd" + ) +} + +fn git_worktree_is_dirty(workspace: &Path) -> bool { + let output = Command::new("git") + .arg("-C") + .arg(workspace) + .args(["status", "--porcelain"]) + .output(); + output + .ok() + .filter(|output| output.status.success()) + .is_some_and(|output| !output.stdout.is_empty()) +} + #[cfg(test)] fn format_unknown_slash_command_message(name: &str) -> String { let suggestions = suggest_slash_commands(name); @@ -3407,6 +3592,18 @@ fn run_resume_command( } if act == "list" => { let sessions = list_managed_sessions().unwrap_or_default(); let session_ids: Vec = sessions.iter().map(|s| s.id.clone()).collect(); + let session_details: Vec = sessions + .iter() + .map(|session| { + serde_json::json!({ + "id": session.id, + "path": session.path.display().to_string(), + "message_count": session.message_count, + "updated_at_ms": session.updated_at_ms, + "lifecycle": session.lifecycle.json_value(), + }) + }) + .collect(); let active_id = session.session_id.clone(); let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}")); Ok(ResumeCommandOutcome { @@ -3415,6 +3612,7 @@ fn run_resume_command( json: Some(serde_json::json!({ "kind": "session_list", "sessions": session_ids, + "session_details": session_details, "active": active_id, })), }) @@ -3646,6 +3844,7 @@ struct ManagedSessionSummary { message_count: usize, parent_session_id: Option, branch_name: Option, + lifecycle: SessionLifecycleSummary, } struct LiveCli { @@ -5251,7 +5450,9 @@ fn resolve_managed_session_path(session_id: &str) -> Result Result, Box> { - Ok(current_session_store()? + let store = current_session_store()?; + let lifecycle = classify_session_lifecycle_for(store.workspace_root()); + Ok(store .list_sessions() .map_err(|e| Box::new(e) as Box)? .into_iter() @@ -5263,12 +5464,15 @@ fn list_managed_sessions() -> Result, Box Result> { - let session = current_session_store()? + let store = current_session_store()?; + let lifecycle = classify_session_lifecycle_for(store.workspace_root()); + let session = store .latest_session() .map_err(|e| Box::new(e) as Box)?; Ok(ManagedSessionSummary { @@ -5279,6 +5483,7 @@ fn latest_managed_session() -> Result Result String::new(), }; lines.push(format!( - " {id:<20} {marker:<10} msgs={msgs:<4} modified={modified}{lineage} path={path}", + " {id:<20} {marker:<10} lifecycle={lifecycle} msgs={msgs:<4} modified={modified}{lineage} path={path}", id = session.id, + lifecycle = session.lifecycle.signal(), msgs = session.message_count, modified = format_session_modified_age(session.modified_epoch_millis), lineage = lineage, @@ -5527,6 +5733,7 @@ fn status_json_value( // .claw/sessions/. Extract the stem (drop the .jsonl extension). path.file_stem().map(|n| n.to_string_lossy().into_owned()) }), + "session_lifecycle": context.session_lifecycle.json_value(), "loaded_config_files": context.loaded_config_files, "discovered_config_files": context.discovered_config_files, "memory_file_count": context.memory_file_count, @@ -5582,7 +5789,7 @@ fn status_context( parse_git_status_metadata(project_context.git_status.as_deref()); let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); Ok(StatusContext { - cwd, + cwd: cwd.clone(), session_path: session_path.map(Path::to_path_buf), loaded_config_files, discovered_config_files, @@ -5590,6 +5797,7 @@ fn status_context( project_root, git_branch, git_summary, + session_lifecycle: classify_session_lifecycle_for(&cwd), sandbox_status, config_load_error, }) @@ -5663,6 +5871,7 @@ fn format_status_report( Unstaged {} Untracked {} Session {} + Lifecycle {} Config files loaded {}/{} Memory files {} Suggested flow /status → /diff → /commit", @@ -5681,6 +5890,7 @@ fn format_status_report( || "live-repl".to_string(), |path| path.display().to_string() ), + context.session_lifecycle.signal(), context.loaded_config_files, context.discovered_config_files, context.memory_file_count, @@ -9025,10 +9235,10 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box