From 20ccd2edecc2ae3866add5b3efef51e6a8a34ac0 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 28 Aug 2026 04:50:17 +0800 Subject: [PATCH 1/3] feat: better background bash --- src/acp/service.rs | 8 +- src/agent/subagent.rs | 2 + src/app.rs | 281 +++++- src/jobs/cli.rs | 354 ++++++++ src/jobs/ledger.rs | 535 ++++++++++++ src/jobs/mod.rs | 60 ++ src/jobs/spawn.rs | 368 ++++++++ src/llm/client.rs | 3 + src/llm/mod.rs | 19 + src/main.rs | 140 ++- src/maintenance/mod.rs | 228 +++++ src/maintenance/tasks.rs | 87 ++ src/prompt/mod.rs | 6 +- src/tools/aisdk_bridge.rs | 11 +- src/tools/bash.rs | 479 ++++++++--- src/tools/bash_kill.rs | 66 ++ src/tools/bash_output.rs | 122 +++ src/tools/bash_restart.rs | 72 ++ src/tools/context.rs | 10 + src/tools/init.rs | 88 +- src/tools/mod.rs | 11 + src/tools/permission.rs | 20 +- src/tools/process_registry.rs | 1180 ++++++++++++++++++++++++++ src/tools/task.rs | 1 + src/tools/terminal_session.rs | 146 +++- src/ui/components/chat.rs | 88 +- src/ui/components/dialog.rs | 2 +- src/views/chat.rs | 48 +- src/views/command_palette.rs | 17 + src/views/jobs_dialog.rs | 938 ++++++++++++++++++++ src/views/mod.rs | 2 + src/views/terminal_session_dialog.rs | 41 +- src/views/which_key.rs | 8 + 33 files changed, 5255 insertions(+), 186 deletions(-) create mode 100644 src/jobs/cli.rs create mode 100644 src/jobs/ledger.rs create mode 100644 src/jobs/mod.rs create mode 100644 src/jobs/spawn.rs create mode 100644 src/maintenance/mod.rs create mode 100644 src/maintenance/tasks.rs create mode 100644 src/tools/bash_kill.rs create mode 100644 src/tools/bash_output.rs create mode 100644 src/tools/bash_restart.rs create mode 100644 src/tools/process_registry.rs create mode 100644 src/views/jobs_dialog.rs diff --git a/src/acp/service.rs b/src/acp/service.rs index fd82fda5..1277e7e2 100644 --- a/src/acp/service.rs +++ b/src/acp/service.rs @@ -635,6 +635,7 @@ impl AcpService { } messages.push(user_message); + let process_registry = std::sync::Arc::new(crate::tools::ProcessRegistry::new()); let prompt_registry = crate::tools::initialize_tool_registry_with_dynamic_config( None, tool_permissions(&session), @@ -644,6 +645,7 @@ impl AcpService { &session.config.merged_config.websearch, &session.config.merged_config.mcp, &session.cwd, + process_registry.clone(), ) .await; let is_git_repo = @@ -669,6 +671,7 @@ impl AcpService { let stream_sender = sender.clone(); let stream_session = session.clone(); let stream_tool_registry = prompt_registry; + let stream_process_registry = process_registry; tokio::spawn(async move { let result = crate::llm::client::stream_llm_with_cancellation( stream_cancellation, @@ -691,6 +694,7 @@ impl AcpService { Some(stream_tool_registry), messages, sender, + stream_process_registry, ) .await; if let Err(error) = result { @@ -1414,7 +1418,9 @@ fn replay_tool_result( fn tool_kind(tool_name: &str) -> ToolKind { match tool_name { - "bash" | "terminal_session" => ToolKind::Execute, + "bash" | "bash_output" | "bash_kill" | "bash_restart" | "terminal_session" => { + ToolKind::Execute + } "webfetch" => ToolKind::Fetch, "grep" | "glob" | "context" => ToolKind::Search, "read" | "view_image" => ToolKind::Read, diff --git a/src/agent/subagent.rs b/src/agent/subagent.rs index 3c0e75c1..12fbf4a1 100644 --- a/src/agent/subagent.rs +++ b/src/agent/subagent.rs @@ -40,6 +40,7 @@ pub async fn run_subagent( cancel_token: tokio_util::sync::CancellationToken, permissions: crate::tools::ToolPermissions, max_steps: Option, + process_registry: Option>, ) -> Result { use crate::aisdk::core::{ chunk::ChunkType, response::StreamTextResponse, stop::StopReason, Message as AisdkMessage, @@ -77,6 +78,7 @@ pub async fn run_subagent( None, session.supports_image_input, cancel_token.clone(), + process_registry, ) .await; let hosted_selection = match crate::config::ConfigLoader::load() { diff --git a/src/app.rs b/src/app.rs index 0030f2a2..82ced582 100644 --- a/src/app.rs +++ b/src/app.rs @@ -44,6 +44,10 @@ use crate::views::connect_dialog::{ init_connect_dialog, render_connect_dialog, }; use crate::views::home::{init_home, render_home}; +use crate::views::jobs_dialog::{ + handle_jobs_dialog_key_event, handle_jobs_dialog_mouse_event, init_jobs_dialog, + render_jobs_dialog, JobsDialogAction, +}; use crate::views::mcp_dialog::{ handle_mcp_dialog_key_event, handle_mcp_dialog_mouse_event, init_mcp_dialog, render_mcp_dialog, McpDialogAction, @@ -102,10 +106,11 @@ use crate::views::title_dialog::{ render_title_dialog, TitleDialogAction, }; use crate::views::{ - AgentsDialogState, ChatState, ConnectDialogState, HomeState, McpDialogState, ModelsDialogState, - MoveSessionDialogState, PermissionDialogState, ProviderOAuthFlowState, QuestionDialogState, - RemoteDialogState, SessionRenameDialogState, SessionsDialogState, StorageDialogState, - SuggestionsPopupState, TerminalSessionDialogState, ThemesDialogState, TitleDialogState, + AgentsDialogState, ChatState, ConnectDialogState, HomeState, JobsDialogState, McpDialogState, + ModelsDialogState, MoveSessionDialogState, PermissionDialogState, ProviderOAuthFlowState, + QuestionDialogState, RemoteDialogState, SessionRenameDialogState, SessionsDialogState, + StorageDialogState, SuggestionsPopupState, TerminalSessionDialogState, ThemesDialogState, + TitleDialogState, }; use crate::{ @@ -236,6 +241,7 @@ pub enum OverlayFocus { SkillsDialog, McpDialog, TimelineDialog, + JobsDialog, CopyActions, MessageActions, CommandPalette, @@ -429,6 +435,7 @@ struct ToolCallViewState { enum SelectionActionTarget { Chat, Input, + JobsDetail, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -844,6 +851,9 @@ pub struct App { pub title_dialog_state: TitleDialogState, pub which_key_state: crate::views::which_key::WhichKeyState, pub timeline_dialog_state: crate::views::timeline_dialog::TimelineDialogState, + pub jobs_dialog_state: JobsDialogState, + /// Last-rendered jobs chip hit area (chat status line); set during render. + jobs_chip_area: Option, /// First Esc arms a double-Esc gesture (cancel while streaming, timeline when idle). /// Matches OpenCode: second Esc confirms; arm expires after [`Self::ESC_ARM_TIMEOUT`]. esc_primed_at: Option, @@ -924,6 +934,8 @@ pub struct App { startup_hydrated: bool, pending_model_override: Option, pending_cli_agent: Option, + /// Shared background/interactive process registry (jobs UI next). + pub process_registry: std::sync::Arc, } /// Cached sum of context tokens for all completed messages of the currently @@ -1012,6 +1024,7 @@ impl App { let mcp_dialog_state = init_mcp_dialog("MCP", vec![]); let which_key_state = crate::views::which_key::init_which_key(); let timeline_dialog_state = crate::views::timeline_dialog::init_timeline_dialog(); + let jobs_dialog_state = init_jobs_dialog(); let command_palette_state = init_command_palette(); let find_bar = FindBar::new(); let storage_dialog_state = init_storage_dialog(); @@ -1092,6 +1105,8 @@ impl App { title_dialog_state, which_key_state, timeline_dialog_state, + jobs_dialog_state, + jobs_chip_area: None, esc_primed_at: None, copy_actions_dialog: None, message_actions_index: None, @@ -1136,7 +1151,7 @@ impl App { config_raw_merged: serde_json::json!({}), custom_instructions: String::new(), terminal_focused: true, - tool_permissions: crate::tools::ToolPermissions::new(cwd_path), + tool_permissions: crate::tools::ToolPermissions::new(cwd_path.clone()), skills_dirs: Vec::new(), is_streaming: false, pending_session_title: None, @@ -1164,6 +1179,9 @@ impl App { startup_hydrated: false, pending_model_override: model_override.map(str::to_string), pending_cli_agent: cli_agent.map(str::to_string), + process_registry: std::sync::Arc::new(crate::tools::ProcessRegistry::with_workdir( + cwd_path, + )), }) } @@ -2876,7 +2894,8 @@ impl App { .with_context(|| format!("failed to switch to {}", path.display()))?; self.cwd = path_text.clone(); self.cached_git_branch_path.clear(); - self.tool_permissions = self.tool_permissions.clone().with_workdir(path); + self.tool_permissions = self.tool_permissions.clone().with_workdir(path.clone()); + self.process_registry.set_workdir_blocking(&path); self.session_manager .switch_current_workspace_path(&path_text) .map_err(|err| anyhow::anyhow!("{err:?}"))?; @@ -2898,6 +2917,29 @@ impl App { self.suggestions_popup_anchor_area(), self.input.selection_screen_row(), ), + SelectionActionTarget::JobsDetail => { + let content = self.jobs_dialog_state.detail_content_area(); + let selection = &self.jobs_dialog_state.selection; + let ((s_line, _), (e_line, _)) = selection.range(); + let top_line = s_line.min(e_line); + let scroll = self.jobs_dialog_state.detail_scroll as usize; + let visible_row = top_line.saturating_sub(scroll) as u16; + let row = content + .y + .saturating_add(visible_row) + .saturating_sub(1) + .max(content.y.saturating_sub(1)); + let width = selection_action_bar_width(state); + let x = content + .x + .saturating_add(content.width.saturating_sub(width) / 2); + Rect { + x, + y: row.min(content.y.saturating_add(content.height.saturating_sub(1))), + width: width.min(content.width.max(1)), + height: 1, + } + } }) } @@ -2992,6 +3034,13 @@ impl App { return true; } + if let Some(text) = self.jobs_dialog_state.selected_text() { + self.copy_text_with_toast(&text, "Copied to clipboard"); + self.jobs_dialog_state.clear_selection(); + self.selection_action_bar = None; + return true; + } + false } @@ -3005,6 +3054,10 @@ impl App { self.input.clear_selection(); return true; } + if self.jobs_dialog_state.selection.active { + self.jobs_dialog_state.clear_selection(); + return true; + } false } @@ -3051,6 +3104,7 @@ impl App { .has_selection() .then(|| self.input.get_selected_text()) .filter(|text| !text.is_empty()), + SelectionActionTarget::JobsDetail => self.jobs_dialog_state.selected_text(), } } @@ -3302,12 +3356,26 @@ impl App { if self.overlay_focus == OverlayFocus::TerminalSessionDialog && self.terminal_session_dialog_state.has_active() { + let job_id = self + .terminal_session_dialog_state + .active_job_id() + .map(str::to_string); let resp = handle_terminal_session_dialog_key_event( &mut self.terminal_session_dialog_state, key, ); - if resp == TerminalSessionResponse::Close { - self.after_terminal_session_overlay_closed(); + match resp { + TerminalSessionResponse::Close => { + if let Some(id) = job_id { + let _ = self.process_registry.kill_blocking(&id); + } + self.after_terminal_session_overlay_closed(); + } + TerminalSessionResponse::Minimize => { + // Park session: keep running, dismiss overlay only. + self.overlay_focus = OverlayFocus::None; + } + TerminalSessionResponse::Handled | TerminalSessionResponse::NotHandled => {} } self.record_overlay_close_after_key(overlay_before_key); return; @@ -3922,6 +3990,22 @@ impl App { crate::views::timeline_dialog::TimelineDialogAction::NotHandled => false, } } + OverlayFocus::JobsDialog => { + if !(key.code == KeyCode::Char('y') + && key.modifiers == event::KeyModifiers::NONE + && self.jobs_dialog_state.is_detail_open() + && self.try_copy_selection()) + { + let action = handle_jobs_dialog_key_event( + &mut self.jobs_dialog_state, + key, + &self.process_registry, + self.session_spinner_frame, + ); + self.handle_jobs_dialog_action(action); + } + true + } OverlayFocus::CopyActions => { if let Some(ref mut dialog) = self.copy_actions_dialog { let event = dialog.handle_key_event(key); @@ -4017,6 +4101,10 @@ impl App { self.overlay_focus = OverlayFocus::None; self.open_timeline_dialog(); } + crate::views::which_key::WhichKeyAction::ShowJobs => { + self.overlay_focus = OverlayFocus::None; + self.open_jobs_dialog(); + } crate::views::which_key::WhichKeyAction::ToggleThinking => { self.overlay_focus = OverlayFocus::None; self.chat_state.chat.toggle_thinking_visible(); @@ -4521,6 +4609,7 @@ impl App { let selection_is_dragging = match state.target { SelectionActionTarget::Chat => self.chat_state.chat.selection.is_dragging, SelectionActionTarget::Input => self.input.is_selection_dragging(), + SelectionActionTarget::JobsDetail => self.jobs_dialog_state.selection.is_dragging, }; if selection_is_dragging { if area.contains(point) && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) @@ -4528,6 +4617,7 @@ impl App { match state.target { SelectionActionTarget::Chat => self.chat_state.chat.finish_selection_drag(), SelectionActionTarget::Input => self.input.finish_selection_drag(), + SelectionActionTarget::JobsDetail => self.jobs_dialog_state.selection.finish(), } } else { return false; @@ -4692,6 +4782,19 @@ impl App { return; } + // Bottom-right jobs chip → open jobs dialog. + if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + && mouse.modifiers.is_empty() + && self.overlay_focus == OverlayFocus::None + { + if let Some(area) = self.jobs_chip_area { + if area.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) { + self.open_jobs_dialog(); + return; + } + } + } + if matches!(mouse.kind, MouseEventKind::Moved) && self.base_focus != BaseFocus::Chat { self.chat_state.chat.clear_hovered_image(); self.chat_state.chat.clear_hovered_hyperlink(); @@ -4955,6 +5058,37 @@ impl App { self.chat_state.chat.clear_highlighted_message(); self.overlay_focus = OverlayFocus::None; } + } else if self.overlay_focus == OverlayFocus::JobsDialog { + let action = handle_jobs_dialog_mouse_event( + &mut self.jobs_dialog_state, + mouse, + &self.process_registry, + self.session_spinner_frame, + ); + self.handle_jobs_dialog_action(action); + if self.jobs_dialog_state.is_detail_open() { + match mouse.kind { + MouseEventKind::Up(MouseButton::Left) + if self.jobs_dialog_state.selection.active + && !self.jobs_dialog_state.selection.is_dragging => + { + self.show_selection_action_bar_for(SelectionActionTarget::JobsDetail); + } + MouseEventKind::Down(MouseButton::Left) + if !self.jobs_dialog_state.selection.active => + { + if self.selection_action_bar + == Some(SelectionActionBarState { + target: SelectionActionTarget::JobsDetail, + can_open_in_editor: false, + }) + { + self.selection_action_bar = None; + } + } + _ => {} + } + } } else if self.overlay_focus == OverlayFocus::CopyActions { if let Some(ref mut dialog) = self.copy_actions_dialog { let event = dialog.handle_mouse_event(mouse); @@ -5756,6 +5890,7 @@ impl App { CommandPaletteAppAction::OpenStorage => self.open_storage_dialog(), CommandPaletteAppAction::OpenSkillsDialog => self.show_skills_dialog(), CommandPaletteAppAction::OpenMcpDialog => self.show_mcp_dialog(), + CommandPaletteAppAction::OpenJobs => self.open_jobs_dialog(), } self.clear_suggestions_and_blur(); } @@ -6938,6 +7073,74 @@ impl App { } } + fn open_jobs_dialog(&mut self) { + self.reset_esc_primed_state(); + self.jobs_dialog_state + .refresh_from_registry(&self.process_registry, self.session_spinner_frame); + self.jobs_dialog_state.show(); + self.overlay_focus = OverlayFocus::JobsDialog; + } + + fn handle_jobs_dialog_action(&mut self, action: JobsDialogAction) { + match action { + JobsDialogAction::Close => { + self.jobs_dialog_state.hide(); + self.selection_action_bar = None; + if self.overlay_focus == OverlayFocus::JobsDialog { + self.overlay_focus = OverlayFocus::None; + } + } + JobsDialogAction::NotHandled => {} + JobsDialogAction::Handled => {} + JobsDialogAction::Kill(id) => { + let _ = self.process_registry.kill_blocking(&id); + // If we killed the parked interactive session, drop it too. + if self.terminal_session_dialog_state.active_job_id() == Some(id.as_str()) { + self.terminal_session_dialog_state.close_current(); + if self.overlay_focus == OverlayFocus::TerminalSessionDialog { + self.after_terminal_session_overlay_closed(); + } + } + if self.jobs_dialog_state.is_detail_open() { + let _ = self + .jobs_dialog_state + .refresh_detail_output(&self.process_registry); + } else { + self.jobs_dialog_state + .refresh_from_registry(&self.process_registry, self.session_spinner_frame); + } + } + JobsDialogAction::Restart(id) => match self.process_registry.restart_blocking(&id) { + Ok(_) => { + if self.jobs_dialog_state.is_detail_open() { + let _ = self + .jobs_dialog_state + .refresh_detail_output(&self.process_registry); + } else { + self.jobs_dialog_state.refresh_from_registry( + &self.process_registry, + self.session_spinner_frame, + ); + } + } + Err(err) => { + crate::emit_log!("[JOBS] restart failed id={} err={}", id, err); + } + }, + JobsDialogAction::FocusInteractive(id) => { + self.jobs_dialog_state.hide(); + self.selection_action_bar = None; + if self.terminal_session_dialog_state.active_job_id() == Some(id.as_str()) + && self.terminal_session_dialog_state.has_active() + { + self.overlay_focus = OverlayFocus::TerminalSessionDialog; + } else if self.overlay_focus == OverlayFocus::JobsDialog { + self.overlay_focus = OverlayFocus::None; + } + } + } + } + fn show_message_actions(&mut self, idx: usize) { let return_focus = if self.overlay_focus == OverlayFocus::TimelineDialog { OverlayFocus::TimelineDialog @@ -8893,6 +9096,16 @@ impl App { self.session_spinner_frame = (self.session_spinner_frame + 1) % 6; self.last_session_spinner_update = std::time::Instant::now(); self.update_sessions_dialog_live_state(true); + if self.jobs_dialog_state.is_visible() { + if self.jobs_dialog_state.is_detail_open() { + let _ = self + .jobs_dialog_state + .refresh_detail_output(&self.process_registry); + } else { + self.jobs_dialog_state + .refresh_from_registry(&self.process_registry, self.session_spinner_frame); + } + } } } @@ -8923,6 +9136,9 @@ impl App { || (self.overlay_focus == OverlayFocus::SessionsDialog && self.sessions_dialog_state.dialog.is_visible() && self.sessions_dialog_has_streaming_rows()) + // Keep ticking while the jobs dialog is open so duration/spinner + // stay live — never call running_count_blocking here (can stall UI). + || self.jobs_dialog_state.is_visible() } fn sessions_dialog_has_streaming_rows(&self) -> bool { @@ -9291,6 +9507,21 @@ impl App { self.handle_terminal_session_stream_event(&tool_call_id, event); true } + crate::llm::ChunkMessage::BackgroundJobEvent { .. } => { + if self.jobs_dialog_state.is_visible() { + if self.jobs_dialog_state.is_detail_open() { + let _ = self + .jobs_dialog_state + .refresh_detail_output(&self.process_registry); + } else { + self.jobs_dialog_state.refresh_from_registry( + &self.process_registry, + self.session_spinner_frame, + ); + } + } + true + } } } @@ -9984,6 +10215,7 @@ impl App { let websearch_config = self.websearch.clone(); let mcp_config = self.mcp.clone(); let custom_instructions = self.custom_instructions.clone(); + let process_registry = self.process_registry.clone(); let cwd = self.cwd.clone(); let is_git_repo = crate::utils::git::is_git_repo(&cwd).unwrap_or(false); @@ -10009,6 +10241,7 @@ impl App { &websearch_config, &mcp_config, &cwd, + process_registry.clone(), ) .await; crate::tools::scope_tool_registry_for_agent( @@ -10057,6 +10290,7 @@ impl App { None, messages, sender_clone.clone(), + process_registry, ); let result: Result>, u64> = match provider_timeout @@ -10632,6 +10866,7 @@ impl App { let parent_agent = self.agent.clone(); let tool_permissions = self.tool_permissions.clone(); let agent_registry = self.agent_registry.clone(); + let process_registry = self.process_registry.clone(); let task_description = format!("{} mention", agent_name); let sender_for_error = sender.clone(); @@ -10651,6 +10886,7 @@ impl App { tool_permissions.clone(), agent_registry.clone(), cancel_token.clone(), + process_registry, ) .await; let task = crate::tools::TaskTool::new(registry) @@ -10997,6 +11233,8 @@ impl App { self.session_manager .get_current_session() .map(|s| s.title.as_str()), + self.process_registry.running_count(), + &mut self.jobs_chip_area, ); if is_suggestions_visible(&self.suggestions_popup_state) @@ -11110,6 +11348,13 @@ impl App { ); } + if self.jobs_dialog_state.is_visible() + && (self.overlay_focus == OverlayFocus::JobsDialog + || self.jobs_dialog_state.dialog.is_visible()) + { + render_jobs_dialog(f, &mut self.jobs_dialog_state, size, colors); + } + if self.overlay_focus == OverlayFocus::CopyActions { if let Some(ref mut dialog) = self.copy_actions_dialog { dialog.render(f, size, colors); @@ -11201,6 +11446,9 @@ impl App { self.suggestions_popup_anchor_area(), self.input.selection_screen_row(), ), + SelectionActionTarget::JobsDetail => { + self.current_selection_action_bar_area().unwrap_or_default() + } }; render_selection_action_bar(f, area, state, &colors); } @@ -11244,10 +11492,14 @@ fn selection_action_for_column(state: SelectionActionBarState, column: usize) -> SelectionAction::Dismiss } SelectionActionTarget::Chat => SelectionAction::Dismiss, - SelectionActionTarget::Input if column < INPUT_SELECTION_ACTION_ESC_COL => { + SelectionActionTarget::Input | SelectionActionTarget::JobsDetail + if column < INPUT_SELECTION_ACTION_ESC_COL => + { SelectionAction::Copy } - SelectionActionTarget::Input => SelectionAction::Dismiss, + SelectionActionTarget::Input | SelectionActionTarget::JobsDetail => { + SelectionAction::Dismiss + } } } @@ -11308,7 +11560,9 @@ fn selection_action_bar_width(state: SelectionActionBarState) -> u16 { match state.target { SelectionActionTarget::Chat if state.can_open_in_editor => SELECTION_ACTION_BAR_WIDTH, SelectionActionTarget::Chat => CHAT_SELECTION_ACTION_ESC_COL_NO_EDITOR as u16 + 4, - SelectionActionTarget::Input => INPUT_SELECTION_ACTION_ESC_COL as u16 + 4, + SelectionActionTarget::Input | SelectionActionTarget::JobsDetail => { + INPUT_SELECTION_ACTION_ESC_COL as u16 + 4 + } } } @@ -11625,6 +11879,8 @@ mod tests { title_dialog_state: init_title_dialog(), which_key_state: crate::views::which_key::init_which_key(), timeline_dialog_state: crate::views::timeline_dialog::init_timeline_dialog(), + jobs_dialog_state: init_jobs_dialog(), + jobs_chip_area: None, esc_primed_at: None, copy_actions_dialog: None, message_actions_index: None, @@ -11697,6 +11953,9 @@ mod tests { startup_hydrated: true, pending_model_override: None, pending_cli_agent: None, + process_registry: std::sync::Arc::new(crate::tools::ProcessRegistry::with_workdir( + std::path::PathBuf::from("."), + )), } } diff --git a/src/jobs/cli.rs b/src/jobs/cli.rs new file mode 100644 index 00000000..05621824 --- /dev/null +++ b/src/jobs/cli.rs @@ -0,0 +1,354 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use std::io::{self, Write}; +use std::path::Path; +use std::time::Duration; + +use super::ledger::{ + canonicalize_workdir, is_pid_alive, list_for_project, list_metas, load_meta, log_path, + prune_dead, refresh_if_dead, CleanupScope, JobMeta, JobStatus, +}; +use super::spawn::{kill_job, restart_job}; + +#[derive(Debug, Clone)] +pub struct ListOpts { + pub all: bool, + /// Human-friendly table (future: interactive TUI picker; for now just a pretty table) + pub interactive: bool, + /// Current process cwd — used when `all` is false. + pub cwd: std::path::PathBuf, +} + +#[derive(Debug, Clone)] +pub struct LogsOpts { + pub id: String, + pub follow: bool, + pub tail: usize, +} + +pub fn run_list(opts: ListOpts) -> Result<()> { + crate::maintenance::run_lazy_once(); + let _ = prune_dead(); + let mut metas = if opts.all { + list_metas()? + } else { + list_for_project(&opts.cwd)? + }; + + // Refresh any that died since prune raced. + for meta in &mut metas { + let _ = refresh_if_dead(meta); + } + + if opts.interactive { + print_table(&metas, opts.all); + } else { + print_tsv(&metas); + } + Ok(()) +} + +pub fn run_logs(opts: LogsOpts) -> Result<()> { + let _ = prune_dead(); + let mut meta = load_meta(&opts.id).with_context(|| format!("unknown job {}", opts.id))?; + let _ = refresh_if_dead(&mut meta); + + let path = log_path(&opts.id); + if !path.exists() { + eprintln!("(no log yet for {})", opts.id); + if !opts.follow { + return Ok(()); + } + } + + let content = if path.exists() { + std::fs::read_to_string(&path).unwrap_or_default() + } else { + String::new() + }; + let lines: Vec<&str> = content.lines().collect(); + let start = lines.len().saturating_sub(opts.tail); + for line in &lines[start..] { + println!("{line}"); + } + + if !opts.follow { + return Ok(()); + } + + // Follow like tail -f: poll for new bytes. + let mut offset = content.len(); + let stdout = io::stdout(); + let mut out = stdout.lock(); + loop { + if path.exists() { + if let Ok(bytes) = std::fs::read(&path) { + if bytes.len() > offset { + let chunk = String::from_utf8_lossy(&bytes[offset..]); + let _ = write!(out, "{chunk}"); + let _ = out.flush(); + offset = bytes.len(); + } + } + } + + if let Ok(mut m) = load_meta(&opts.id) { + let _ = refresh_if_dead(&mut m); + if m.status.is_terminal() && !is_pid_alive(m.pid) { + // Final drain. + if let Ok(bytes) = std::fs::read(&path) { + if bytes.len() > offset { + let chunk = String::from_utf8_lossy(&bytes[offset..]); + let _ = write!(out, "{chunk}"); + let _ = out.flush(); + } + } + break; + } + } + + std::thread::sleep(Duration::from_millis(200)); + } + Ok(()) +} + +pub fn run_stop(id: &str) -> Result<()> { + let meta = kill_job(id)?; + println!( + "stopped {}\t{}\tpid={}", + meta.id, + meta.status.as_str(), + meta.pid + ); + Ok(()) +} + +/// Stop every running job in scope (current project by default, or `--all`). +pub fn run_stop_all(all: bool, cwd: &Path) -> Result<()> { + crate::maintenance::run_lazy_once(); + let _ = prune_dead(); + let metas = if all { + list_metas()? + } else { + list_for_project(cwd)? + }; + + let running: Vec = metas + .into_iter() + .filter(|m| matches!(m.status, JobStatus::Running) || is_pid_alive(m.pid)) + .collect(); + + if running.is_empty() { + if all { + println!("(no running jobs)"); + } else { + println!("(no running jobs in this project — try --all)"); + } + return Ok(()); + } + + let mut stopped = 0usize; + for meta in running { + match kill_job(&meta.id) { + Ok(m) => { + println!("stopped {}\t{}\tpid={}", m.id, m.status.as_str(), m.pid); + stopped += 1; + } + Err(err) => eprintln!("failed {}\t{err:#}", meta.id), + } + } + println!("stopped {stopped}"); + Ok(()) +} + +pub fn run_restart(id: &str) -> Result<()> { + let meta = restart_job(id)?; + println!("restarted {}\tpid={}\t{}", meta.id, meta.pid, meta.name); + Ok(()) +} + +fn print_tsv(metas: &[JobMeta]) { + if metas.is_empty() { + println!("(no jobs)"); + return; + } + for m in metas { + let dur = format_duration(m); + let status = m.status.as_str(); + let exit = m + .exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "-".into()); + println!( + "{}\t{}\t{}\t{}\t{}\t{}", + m.id, status, m.pid, dur, exit, m.name + ); + } +} + +/// Human-friendly table (future: interactive TUI picker; for now just a pretty table) +fn print_table(metas: &[JobMeta], show_workdir: bool) { + if metas.is_empty() { + println!("(no jobs)"); + return; + } + + println!( + "{:<22} {:<8} {:<20} {:<10} {}", + "ID", + "STATUS", + "NAME", + "DURATION", + if show_workdir { + "COMMAND (workdir)" + } else { + "COMMAND" + } + ); + for m in metas { + let icon = status_icon(m.status); + let dur = format_duration(m); + let cmd = truncate(&m.command, 48); + if show_workdir { + println!( + "{:<22} {icon}{:<7} {:<20} {:<10} {} ({})", + m.id, + m.status.as_str(), + truncate(&m.name, 20), + dur, + cmd, + m.workdir + ); + } else { + println!( + "{:<22} {icon}{:<7} {:<20} {:<10} {}", + m.id, + m.status.as_str(), + truncate(&m.name, 20), + dur, + cmd + ); + } + } +} + +fn status_icon(status: JobStatus) -> char { + match status { + JobStatus::Running => '●', + JobStatus::Exited => '✓', + JobStatus::Killed => '✗', + JobStatus::Failed => '!', + } +} + +fn format_duration(m: &JobMeta) -> String { + let end = m.ended_at.unwrap_or_else(Utc::now); + let secs = (end - m.started_at).num_seconds().max(0) as u64; + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m{}s", secs / 60, secs % 60) + } else { + format!("{}h{}m", secs / 3600, (secs % 3600) / 60) + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out + } +} + +/// Resolve whether `id` looks like a ledger job (vs interactive in-memory). +pub fn is_ledger_job(id: &str) -> bool { + Path::new(&log_path(id)) + .parent() + .map(|p| p.join("meta.json").exists()) + .unwrap_or(false) + || load_meta(id).is_ok() +} + +/// Clean scopes: +/// - default: current session (most recently updated session in this project) +/// - `--all`: current project +/// - `--global`: everything crabcode knows +pub fn run_clean( + all: bool, + global: bool, + older_than: &str, + dry_run: bool, + cwd: &Path, +) -> Result<()> { + if all && global { + anyhow::bail!("use either --all (project) or --global, not both"); + } + + // `--all` / `--global` imply wipe finished jobs regardless of age. + let max_age = if all || global { + std::time::Duration::from_secs(0) + } else { + crate::maintenance::parse_age(older_than)? + }; + + let scope = if global { + CleanupScope::Global + } else if all { + CleanupScope::Project { + workdir: cwd.to_path_buf(), + } + } else { + let session_id = resolve_current_session_id(cwd)?; + CleanupScope::Session { + session_id, + workdir: Some(cwd.to_path_buf()), + } + }; + + let mut m = crate::maintenance::Maintenance { tasks: vec![] }; + m.register(Box::new(crate::maintenance::tasks::JobCleanupWithAge { + max_age, + scope, + })); + let report = m.run(&crate::maintenance::RunOpts { + dry_run, + only: Some("jobs".into()), + })?; + if let Some(t) = report.tasks.first() { + println!("{}", t.message); + } else { + println!("(no jobs maintenance task ran)"); + } + Ok(()) +} + +/// Most recently updated session in this project workspace (for CLI default clean). +fn resolve_current_session_id(cwd: &Path) -> Result { + let history = crate::persistence::history::HistoryDAO::new_for_workspace(cwd)?; + let sessions = history.list_sessions()?; + let wanted = canonicalize_workdir(cwd); + let session = sessions + .into_iter() + .find(|s| canonicalize_workdir(Path::new(&s.workspace_path)) == wanted) + .ok_or_else(|| { + anyhow::anyhow!( + "no session found for this project — pass --all to clean the project, or --global" + ) + })?; + Ok(session.session_identifier) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn print_tsv_empty_prints_no_jobs() { + // Empty TSV path should print the human empty message (agents parse rows only when present). + let metas: Vec = Vec::new(); + print_tsv(&metas); + } +} diff --git a/src/jobs/ledger.rs b/src/jobs/ledger.rs new file mode 100644 index 00000000..b0f18284 --- /dev/null +++ b/src/jobs/ledger.rs @@ -0,0 +1,535 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::persistence::get_data_dir; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum JobStatus { + Running, + Exited, + Killed, + Failed, +} + +impl JobStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Exited => "exited", + Self::Killed => "killed", + Self::Failed => "failed", + } + } + + pub fn is_terminal(self) -> bool { + !matches!(self, Self::Running) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobMeta { + pub id: String, + pub pid: u32, + #[serde(default)] + pub pgid: Option, + pub command: String, + pub name: String, + pub workdir: String, + #[serde(default)] + pub session_id: Option, + pub started_at: DateTime, + #[serde(default)] + pub ended_at: Option>, + pub status: JobStatus, + #[serde(default)] + pub exit_code: Option, +} + +pub fn jobs_root() -> PathBuf { + get_data_dir().join("jobs") +} + +pub fn job_dir(id: &str) -> PathBuf { + jobs_root().join(id) +} + +pub fn meta_path(id: &str) -> PathBuf { + job_dir(id).join("meta.json") +} + +pub fn log_path(id: &str) -> PathBuf { + job_dir(id).join("output.log") +} + +pub fn canonicalize_workdir(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } + }) +} + +pub fn save_meta(meta: &JobMeta) -> Result<()> { + let dir = job_dir(&meta.id); + fs::create_dir_all(&dir) + .with_context(|| format!("failed to create job dir {}", dir.display()))?; + let path = meta_path(&meta.id); + let json = serde_json::to_vec_pretty(meta).context("serialize job meta")?; + // Write via temp + rename when possible; fall back to direct write. + let tmp = dir.join(format!("meta.{}.tmp", std::process::id())); + match fs::write(&tmp, &json) { + Ok(()) => { + if let Err(err) = fs::rename(&tmp, &path) { + // Best-effort cleanup + direct write fallback (e.g. cross-device). + let _ = fs::remove_file(&tmp); + fs::write(&path, &json).with_context(|| { + format!("write {} (after rename failed: {err})", path.display()) + })?; + } + } + Err(err) => { + fs::write(&path, &json) + .with_context(|| format!("write {} (tmp write failed: {err})", path.display()))?; + } + } + Ok(()) +} + +pub fn load_meta(id: &str) -> Result { + let path = meta_path(id); + let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let meta: JobMeta = serde_json::from_slice(&bytes) + .with_context(|| format!("parse job meta {}", path.display()))?; + Ok(meta) +} + +pub fn list_metas() -> Result> { + let root = jobs_root(); + if !root.exists() { + return Ok(Vec::new()); + } + + let mut out = Vec::new(); + for entry in fs::read_dir(&root).with_context(|| format!("read {}", root.display()))? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let id = entry.file_name(); + let Some(id) = id.to_str() else { + continue; + }; + match load_meta(id) { + Ok(meta) => out.push(meta), + Err(_) => continue, + } + } + + out.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + Ok(out) +} + +pub fn list_for_project(workdir: &Path) -> Result> { + let wanted = canonicalize_workdir(workdir); + let wanted_str = wanted.to_string_lossy(); + let mut out = Vec::new(); + for meta in list_metas()? { + let job_wd = canonicalize_workdir(Path::new(&meta.workdir)); + if job_wd.to_string_lossy() == wanted_str { + out.push(meta); + } + } + Ok(out) +} + +/// If a job is still marked `running` but its pid is dead, mark it exited. +/// Keeps meta + log on disk (no aggressive delete). +pub fn prune_dead() -> Result { + let mut updated = 0usize; + for mut meta in list_metas()? { + if meta.status != JobStatus::Running { + continue; + } + if is_pid_alive(meta.pid) { + continue; + } + meta.status = JobStatus::Exited; + meta.ended_at = Some(Utc::now()); + save_meta(&meta)?; + updated += 1; + } + Ok(updated) +} + +pub fn is_pid_alive(pid: u32) -> bool { + if pid == 0 { + return false; + } + #[cfg(unix)] + { + // SAFETY: kill(pid, 0) is a liveness probe; no signal is delivered. + let rc = unsafe { libc::kill(pid as i32, 0) }; + if rc == 0 { + return true; + } + let err = std::io::Error::last_os_error(); + // EPERM means the process exists but we can't signal it. + return err.raw_os_error() == Some(libc::EPERM); + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // Best-effort: try OpenProcess via tasklist / query. Prefer CreateToolhelp. + // Fall back to probing via `tasklist` is slow; use Win32 OpenProcess. + windows_pid_alive(pid) + } + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + false + } +} + +#[cfg(windows)] +fn windows_pid_alive(pid: u32) -> bool { + // SYNCHRONIZE access is enough to probe existence. + const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; + extern "system" { + fn OpenProcess(access: u32, inherit: i32, pid: u32) -> isize; + fn CloseHandle(handle: isize) -> i32; + fn GetExitCodeProcess(handle: isize, code: *mut u32) -> i32; + } + const STILL_ACTIVE: u32 = 259; + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle == 0 || handle == -1 { + return false; + } + let mut code: u32 = 0; + let ok = GetExitCodeProcess(handle, &mut code); + CloseHandle(handle); + ok != 0 && code == STILL_ACTIVE + } +} + +pub fn new_job_id() -> String { + format!("job_{}", cuid2::create_id()) +} + +pub fn mark_status(id: &str, status: JobStatus, exit_code: Option) -> Result { + let mut meta = load_meta(id)?; + if meta.status.is_terminal() && status != JobStatus::Killed { + // Already terminal; keep existing unless explicitly killed after. + return Ok(meta); + } + meta.status = status; + meta.exit_code = exit_code.or(meta.exit_code); + if meta.ended_at.is_none() { + meta.ended_at = Some(Utc::now()); + } + save_meta(&meta)?; + Ok(meta) +} + +pub fn ensure_jobs_root() -> Result<()> { + fs::create_dir_all(jobs_root()).with_context(|| format!("create {}", jobs_root().display()))?; + Ok(()) +} + +/// Refresh a single meta if its pid died while status was still running. +pub fn refresh_if_dead(meta: &mut JobMeta) -> Result { + if meta.status != JobStatus::Running { + return Ok(false); + } + if is_pid_alive(meta.pid) { + return Ok(false); + } + meta.status = JobStatus::Exited; + meta.ended_at = Some(Utc::now()); + save_meta(meta)?; + Ok(true) +} + +/// Scope for finished-job cleanup. +#[derive(Debug, Clone)] +pub enum CleanupScope { + /// Jobs stamped with this session id (also drops unscoped jobs in `workdir` if set). + Session { + session_id: String, + /// When set, also include finished jobs in this project with no session_id + /// (legacy jobs spawned before session stamping). + workdir: Option, + }, + /// All jobs whose workdir matches this project. + Project { workdir: PathBuf }, + /// Every finished job crabcode knows about. + Global, +} + +fn scope_matches(meta: &JobMeta, scope: &CleanupScope) -> bool { + match scope { + CleanupScope::Global => true, + CleanupScope::Project { workdir } => { + let wanted = canonicalize_workdir(workdir); + canonicalize_workdir(Path::new(&meta.workdir)) == wanted + } + CleanupScope::Session { + session_id, + workdir, + } => { + if meta.session_id.as_deref() == Some(session_id.as_str()) { + return true; + } + // Legacy: no session stamp — only if in the same project. + if meta.session_id.is_none() { + if let Some(wd) = workdir { + let wanted = canonicalize_workdir(wd); + return canonicalize_workdir(Path::new(&meta.workdir)) == wanted; + } + } + false + } + } +} + +/// Delete finished jobs older than `max_age` within `scope`. +/// Returns (removed, skipped_running_or_fresh_or_out_of_scope). +pub fn cleanup_finished( + max_age: std::time::Duration, + dry_run: bool, + scope: &CleanupScope, +) -> Result<(usize, usize)> { + prune_dead()?; + let chrono_max = + chrono::Duration::from_std(max_age).unwrap_or_else(|_| chrono::Duration::days(7)); + let cutoff = Utc::now() - chrono_max; + let mut removed = 0usize; + let mut skipped = 0usize; + for meta in list_metas()? { + if !scope_matches(&meta, scope) { + skipped += 1; + continue; + } + if matches!(meta.status, JobStatus::Running) { + skipped += 1; + continue; + } + let ended = meta.ended_at.unwrap_or(meta.started_at); + if ended > cutoff { + skipped += 1; + continue; + } + if !dry_run { + let dir = job_dir(&meta.id); + let _ = fs::remove_dir_all(&dir); + } + removed += 1; + } + Ok((removed, skipped)) +} + +/// Global cleanup (auto / maintenance default). +pub fn cleanup_finished_global( + max_age: std::time::Duration, + dry_run: bool, +) -> Result<(usize, usize)> { + cleanup_finished(max_age, dry_run, &CleanupScope::Global) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jobs::test_env::TempState; + + #[test] + fn save_load_roundtrip() { + let _state = TempState::new(); + let meta = JobMeta { + id: "job_test1".into(), + pid: 42, + pgid: Some(42), + command: "echo hi".into(), + name: "echo".into(), + workdir: "/tmp/proj".into(), + session_id: None, + started_at: Utc::now(), + ended_at: None, + status: JobStatus::Running, + exit_code: None, + }; + save_meta(&meta).unwrap(); + let loaded = load_meta("job_test1").unwrap(); + assert_eq!(loaded.id, meta.id); + assert_eq!(loaded.pid, 42); + assert_eq!(loaded.command, "echo hi"); + assert_eq!(loaded.status, JobStatus::Running); + } + + #[test] + fn list_for_project_filters_by_canonical_workdir() { + let _state = TempState::new(); + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + let meta_a = JobMeta { + id: "job_a".into(), + pid: 1, + pgid: Some(1), + command: "a".into(), + name: "a".into(), + workdir: a.path().to_string_lossy().into(), + session_id: None, + started_at: Utc::now(), + ended_at: None, + status: JobStatus::Running, + exit_code: None, + }; + let meta_b = JobMeta { + id: "job_b".into(), + pid: 2, + pgid: Some(2), + command: "b".into(), + name: "b".into(), + workdir: b.path().to_string_lossy().into(), + session_id: None, + started_at: Utc::now(), + ended_at: None, + status: JobStatus::Exited, + exit_code: Some(0), + }; + save_meta(&meta_a).unwrap(); + save_meta(&meta_b).unwrap(); + + let listed = list_for_project(a.path()).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "job_a"); + + let listed_b = list_for_project(b.path()).unwrap(); + assert_eq!(listed_b.len(), 1); + assert_eq!(listed_b[0].id, "job_b"); + } + + #[test] + fn prune_marks_dead_running_jobs() { + let _state = TempState::new(); + let meta = JobMeta { + id: "job_dead".into(), + // Unlikely to be a live pid we own; 1 may be init and alive on unix. + // Use a very high pid that shouldn't exist. + pid: u32::MAX - 7, + pgid: Some(u32::MAX - 7), + command: "gone".into(), + name: "gone".into(), + workdir: "/tmp".into(), + session_id: None, + started_at: Utc::now(), + ended_at: None, + status: JobStatus::Running, + exit_code: None, + }; + save_meta(&meta).unwrap(); + let n = prune_dead().unwrap(); + assert!(n >= 1); + let loaded = load_meta("job_dead").unwrap(); + assert_eq!(loaded.status, JobStatus::Exited); + assert!(loaded.ended_at.is_some()); + } + + #[test] + fn cleanup_finished_removes_old_keeps_running_and_fresh() { + let _state = TempState::new(); + let now = Utc::now(); + let old_ended = now - chrono::Duration::days(10); + let fresh_ended = now - chrono::Duration::hours(1); + + let old = JobMeta { + id: "job_old".into(), + pid: 1, + pgid: None, + command: "echo old".into(), + name: "old".into(), + workdir: "/tmp/proj".into(), + session_id: None, + started_at: old_ended, + ended_at: Some(old_ended), + status: JobStatus::Exited, + exit_code: Some(0), + }; + let fresh = JobMeta { + id: "job_fresh".into(), + pid: 2, + pgid: None, + command: "echo fresh".into(), + name: "fresh".into(), + workdir: "/tmp/proj".into(), + session_id: None, + started_at: fresh_ended, + ended_at: Some(fresh_ended), + status: JobStatus::Exited, + exit_code: Some(0), + }; + let running = JobMeta { + id: "job_running".into(), + pid: std::process::id(), + pgid: None, + command: "sleep 999".into(), + name: "running".into(), + workdir: "/tmp/proj".into(), + session_id: None, + started_at: old_ended, + ended_at: None, + status: JobStatus::Running, + exit_code: None, + }; + save_meta(&old).unwrap(); + save_meta(&fresh).unwrap(); + save_meta(&running).unwrap(); + + let (removed, skipped) = cleanup_finished( + std::time::Duration::from_secs(7 * 24 * 3600), + false, + &CleanupScope::Global, + ) + .unwrap(); + assert_eq!(removed, 1); + assert!(skipped >= 2); + assert!(load_meta("job_old").is_err()); + assert!(load_meta("job_fresh").is_ok()); + assert!(load_meta("job_running").is_ok()); + } + + #[test] + fn cleanup_finished_dry_run_does_not_delete() { + let _state = TempState::new(); + let old_ended = Utc::now() - chrono::Duration::days(10); + let old = JobMeta { + id: "job_dry".into(), + pid: 1, + pgid: None, + command: "echo dry".into(), + name: "dry".into(), + workdir: "/tmp/proj".into(), + session_id: None, + started_at: old_ended, + ended_at: Some(old_ended), + status: JobStatus::Failed, + exit_code: Some(1), + }; + save_meta(&old).unwrap(); + let (removed, _) = cleanup_finished( + std::time::Duration::from_secs(7 * 24 * 3600), + true, + &CleanupScope::Global, + ) + .unwrap(); + assert_eq!(removed, 1); + assert!(load_meta("job_dry").is_ok()); + } +} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs new file mode 100644 index 00000000..ac7c90df --- /dev/null +++ b/src/jobs/mod.rs @@ -0,0 +1,60 @@ +//! Survive-quit background jobs: on-disk ledger + detached OS processes. +//! +//! No daemon. Jobs are keyed under `~/.local/state/crabcode/jobs//` and +//! filtered by project `workdir`. Interactive PTY jobs stay in-memory only. + +pub mod cli; +pub mod ledger; +pub mod spawn; + +#[cfg(test)] +pub(crate) mod test_env { + use std::path::{Path, PathBuf}; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + /// Global lock so concurrent tests don't clobber `XDG_STATE_HOME`. + pub fn lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) + } + + pub struct TempState { + _guard: MutexGuard<'static, ()>, + dir: tempfile::TempDir, + prev: Option, + } + + impl TempState { + pub fn new() -> Self { + let guard = lock(); + let dir = tempfile::tempdir().expect("tempdir"); + let prev = std::env::var_os("XDG_STATE_HOME"); + std::env::set_var("XDG_STATE_HOME", dir.path()); + Self { + _guard: guard, + dir, + prev, + } + } + + pub fn path(&self) -> &Path { + self.dir.path() + } + } + + impl Drop for TempState { + fn drop(&mut self) { + match &self.prev { + Some(v) => std::env::set_var("XDG_STATE_HOME", v), + None => std::env::remove_var("XDG_STATE_HOME"), + } + } + } + + #[allow(dead_code)] + pub fn data_dir(state: &TempState) -> PathBuf { + state.path().join("crabcode") + } +} diff --git a/src/jobs/spawn.rs b/src/jobs/spawn.rs new file mode 100644 index 00000000..49eb792e --- /dev/null +++ b/src/jobs/spawn.rs @@ -0,0 +1,368 @@ +use anyhow::{Context, Result}; +use chrono::Utc; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::process::{Command as StdCommand, Stdio}; + +use super::ledger::{ + canonicalize_workdir, ensure_jobs_root, is_pid_alive, job_dir, load_meta, log_path, + mark_status, new_job_id, save_meta, JobMeta, JobStatus, +}; + +pub struct SpawnDetachedOpts<'a> { + pub command: &'a str, + pub name: &'a str, + pub workdir: &'a Path, + pub session_id: Option, +} + +/// Spawn a detached background job that survives crabcode exit. +/// +/// Unix: `/bin/sh -c command` in its own process group, stdout/stderr → output.log, +/// Child dropped immediately (lazy status updates via prune). +/// Windows: best-effort CREATE_NEW_PROCESS_GROUP + log redirect. +pub async fn spawn_detached(opts: SpawnDetachedOpts<'_>) -> Result { + spawn_detached_blocking(opts) +} + +/// Sync spawn path used by CLI and by `restart_job`. +pub fn spawn_detached_blocking(opts: SpawnDetachedOpts<'_>) -> Result { + ensure_jobs_root()?; + let id = new_job_id(); + spawn_detached_into(id, &opts, None) +} + +fn spawn_detached_into( + id: String, + opts: &SpawnDetachedOpts<'_>, + log_prefix: Option<&str>, +) -> Result { + ensure_jobs_root()?; + + let workdir = canonicalize_workdir(opts.workdir); + let dir = job_dir(&id); + std::fs::create_dir_all(&dir).with_context(|| format!("create job dir {}", dir.display()))?; + + let log = log_path(&id); + let mut log_file = OpenOptions::new() + .create(true) + .append(true) + .open(&log) + .with_context(|| format!("open log {}", log.display()))?; + if let Some(prefix) = log_prefix { + write!(log_file, "{prefix}") + .with_context(|| format!("write restart marker {}", log.display()))?; + let _ = log_file.sync_all(); + } + let _ = log_file.sync_all(); + let log_err = log_file + .try_clone() + .with_context(|| format!("clone log fd {}", log.display()))?; + + let mut cmd = if cfg!(windows) { + let mut c = StdCommand::new("cmd"); + c.arg("/C").arg(opts.command); + c + } else { + let mut c = StdCommand::new("/bin/sh"); + c.arg("-c").arg(opts.command); + c + }; + + cmd.current_dir(&workdir); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::from(log_file)); + cmd.stderr(Stdio::from(log_err)); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + // Put child in its own process group (equivalent to setpgid(0,0)). + if libc::setpgid(0, 0) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW); + } + + let child = cmd + .spawn() + .with_context(|| format!("spawn detached: {}", opts.command))?; + + let pid = child.id(); + + // Detach: drop Child without waiting / killing. + drop(child); + + let meta = JobMeta { + id: id.clone(), + pid, + pgid: Some(pid), + command: opts.command.to_string(), + name: opts.name.to_string(), + workdir: workdir.to_string_lossy().into_owned(), + session_id: opts.session_id.clone(), + started_at: Utc::now(), + ended_at: None, + status: JobStatus::Running, + exit_code: None, + }; + save_meta(&meta)?; + Ok(meta) +} + +/// Kill a ledger-backed job by process group (Unix) or pid (Windows), then update meta. +pub fn kill_job(id: &str) -> Result { + let mut meta = load_meta(id).with_context(|| format!("unknown job {id}"))?; + + if meta.status.is_terminal() && !is_pid_alive(meta.pid) { + return Ok(meta); + } + + terminate_job_process(&meta); + + meta = mark_status(id, JobStatus::Killed, None)?; + Ok(meta) +} + +/// Restart a ledger-backed job, reusing the same id. +/// +/// If the process is still alive, terminates it (without finalizing as permanently killed), +/// appends a restart marker to `output.log`, then re-spawns the same command/cwd/name. +/// Already-dead jobs are still restarted (primary use case). +pub fn restart_job(id: &str) -> Result { + let meta = load_meta(id).with_context(|| format!("unknown job {id}"))?; + + if is_pid_alive(meta.pid) || meta.status == JobStatus::Running { + terminate_job_process(&meta); + // Wait briefly for death so ports/files can be released before respawn. + for _ in 0..40 { + if !is_pid_alive(meta.pid) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + } + + let marker = format!( + "\n--- restarted at {} ---\n", + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + ); + let opts = SpawnDetachedOpts { + command: &meta.command, + name: &meta.name, + workdir: Path::new(&meta.workdir), + session_id: meta.session_id.clone(), + }; + spawn_detached_into(meta.id.clone(), &opts, Some(&marker)) +} + +/// Sync wrapper matching `kill_job` style (restart is already sync). +pub fn restart_job_blocking(id: &str) -> Result { + restart_job(id) +} + +fn terminate_job_process(meta: &JobMeta) { + let target = meta.pgid.unwrap_or(meta.pid); + kill_process_group(target); + + // Brief grace: if still alive, escalate. + if is_pid_alive(meta.pid) { + std::thread::sleep(std::time::Duration::from_millis(50)); + kill_process_group(target); + } +} + +fn kill_process_group(pid: u32) { + if pid == 0 { + return; + } + #[cfg(unix)] + { + unsafe { + libc::kill(-(pid as i32), libc::SIGTERM); + } + } + #[cfg(windows)] + { + // Best-effort: taskkill the process tree. + let _ = StdCommand::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +/// Read output.log from a byte offset. Returns (content, next_offset, file_len). +pub fn read_log_from(id: &str, since_byte: usize) -> Result<(String, usize, usize)> { + let path = log_path(id); + if !path.exists() { + return Ok((String::new(), since_byte, 0)); + } + let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let len = bytes.len(); + let start = since_byte.min(len); + let slice = &bytes[start..]; + // Lossy is fine for mixed binary-ish tool output. + let content = String::from_utf8_lossy(slice).into_owned(); + Ok((content, len, len)) +} + +/// Poll log growth / process death up to `wait_ms`. +pub async fn wait_for_log_growth( + id: &str, + since_byte: usize, + wait_ms: u64, +) -> Result<(String, usize, bool)> { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(wait_ms); + let mut meta = load_meta(id)?; + + loop { + let (content, next, _len) = read_log_from(id, since_byte)?; + if !content.is_empty() { + let alive = meta.status == JobStatus::Running && is_pid_alive(meta.pid); + return Ok((content, next, !alive)); + } + + if meta.status == JobStatus::Running && !is_pid_alive(meta.pid) { + let _ = super::ledger::refresh_if_dead(&mut meta); + let (content, next, _) = read_log_from(id, since_byte)?; + return Ok((content, next, true)); + } + + if meta.status.is_terminal() { + let (content, next, _) = read_log_from(id, since_byte)?; + return Ok((content, next, true)); + } + + if tokio::time::Instant::now() >= deadline { + let (content, next, _) = read_log_from(id, since_byte)?; + let exited = meta.status.is_terminal() || !is_pid_alive(meta.pid); + return Ok((content, next, exited)); + } + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + if let Ok(m) = load_meta(id) { + meta = m; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jobs::test_env::TempState; + use std::time::Duration; + + #[tokio::test] + async fn spawn_and_kill_sleep() { + let _state = TempState::new(); + let meta = spawn_detached(SpawnDetachedOpts { + command: "sleep 30", + name: "sleep-test", + workdir: Path::new("."), + session_id: None, + }) + .await + .unwrap(); + assert!(is_pid_alive(meta.pid)); + let killed = kill_job(&meta.id).unwrap(); + assert_eq!(killed.status, JobStatus::Killed); + // Give the OS a moment. + std::thread::sleep(Duration::from_millis(100)); + assert!(!is_pid_alive(meta.pid) || killed.status == JobStatus::Killed); + } + + #[tokio::test] + async fn read_log_captures_output() { + let _state = TempState::new(); + let meta = spawn_detached(SpawnDetachedOpts { + command: "printf 'hello-world\\n'", + name: "echo-test", + workdir: Path::new("."), + session_id: None, + }) + .await + .unwrap(); + // Wait for process to exit and flush. + for _ in 0..50 { + if !is_pid_alive(meta.pid) { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let (content, _, _) = read_log_from(&meta.id, 0).unwrap(); + assert!( + content.contains("hello-world"), + "log content was: {content:?}" + ); + } + + #[test] + fn restart_reuses_id_and_gets_new_pid() { + let _state = TempState::new(); + let meta = spawn_detached_blocking(SpawnDetachedOpts { + command: "sleep 30", + name: "restart-test", + workdir: Path::new("."), + session_id: None, + }) + .unwrap(); + let old_pid = meta.pid; + assert!(is_pid_alive(old_pid)); + + let restarted = restart_job(&meta.id).unwrap(); + assert_eq!(restarted.id, meta.id); + assert_eq!(restarted.status, JobStatus::Running); + assert!(restarted.ended_at.is_none()); + assert_ne!(restarted.pid, old_pid); + assert!(is_pid_alive(restarted.pid)); + + let (log, _, _) = read_log_from(&meta.id, 0).unwrap(); + assert!( + log.contains("--- restarted at "), + "missing restart marker in log: {log:?}" + ); + + let _ = kill_job(&meta.id); + } + + #[test] + fn restart_exited_job() { + let _state = TempState::new(); + let meta = spawn_detached_blocking(SpawnDetachedOpts { + command: "true", + name: "restart-exited", + workdir: Path::new("."), + session_id: None, + }) + .unwrap(); + for _ in 0..50 { + if !is_pid_alive(meta.pid) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + let mut loaded = load_meta(&meta.id).unwrap(); + let _ = crate::jobs::ledger::refresh_if_dead(&mut loaded); + + let restarted = restart_job(&meta.id).unwrap(); + assert_eq!(restarted.id, meta.id); + assert_eq!(restarted.status, JobStatus::Running); + assert!(is_pid_alive(restarted.pid)); + let _ = kill_job(&meta.id); + } +} diff --git a/src/llm/client.rs b/src/llm/client.rs index d4ddd94d..803b8882 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -580,6 +580,7 @@ pub async fn stream_llm_with_cancellation( tool_registry: Option, messages: Vec, sender: crate::llm::ChunkSender, + process_registry: std::sync::Arc, ) -> Result<(), DynError> { struct SessionConfigGuard(crate::agent::config::LlmSessionRegistration); impl Drop for SessionConfigGuard { @@ -618,6 +619,7 @@ pub async fn stream_llm_with_cancellation( &websearch_config, &mcp_config, &workspace, + process_registry.clone(), ) .await; crate::tools::refresh_mcp_tools(®istry, &mcp_config, &workspace).await; @@ -677,6 +679,7 @@ pub async fn stream_llm_with_cancellation( None, request_config.supports_image_input, cancel_token.clone(), + Some(process_registry.clone()), ) .await; if text_only_image_turn { diff --git a/src/llm/mod.rs b/src/llm/mod.rs index b6e180f8..780d79d3 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -43,6 +43,10 @@ pub enum ChunkMessage { tool_call_id: String, event: TerminalSessionEvent, }, + BackgroundJobEvent { + job_id: String, + event: BackgroundJobEventKind, + }, End, Failed(String), Cancelled, @@ -52,5 +56,20 @@ pub enum ChunkMessage { }, } +#[derive(Debug, Clone)] +pub enum BackgroundJobEventKind { + Started { + command: String, + description: String, + kind: String, + }, + /// Reserved for incremental output streaming (unused in v1). + Output, + Exited { + exit_code: Option, + }, + Killed, +} + pub type ChunkSender = mpsc::UnboundedSender; pub type ChunkReceiver = mpsc::UnboundedReceiver; diff --git a/src/main.rs b/src/main.rs index 588f98d0..05f72e52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,10 @@ mod autocomplete; mod command; mod config; mod herdr; +mod jobs; mod llm; mod logging; +mod maintenance; mod mcp; mod model; mod notify; @@ -402,6 +404,7 @@ async fn run_print_mode( .get(&agent_mode) .and_then(|agent| agent.max_steps); let cancel_token = tokio_util::sync::CancellationToken::new(); + let process_registry = std::sync::Arc::new(crate::tools::ProcessRegistry::new()); let prompt_registry = crate::tools::initialize_tool_registry_with_dynamic_config( Some(sender.clone()), @@ -412,6 +415,7 @@ async fn run_print_mode( &websearch_config, &mcp_config, &cwd, + process_registry.clone(), ) .await; let prompt_registry = crate::tools::scope_tool_registry_for_agent( @@ -458,6 +462,7 @@ async fn run_print_mode( Some(prompt_registry), messages, sender, + process_registry, ) .await { @@ -483,7 +488,8 @@ async fn run_print_mode( | crate::llm::ChunkMessage::StreamRollback { .. } | crate::llm::ChunkMessage::SubagentStarted { .. } | crate::llm::ChunkMessage::SubagentChunk { .. } - | crate::llm::ChunkMessage::TerminalSessionEvent { .. } => {} + | crate::llm::ChunkMessage::TerminalSessionEvent { .. } + | crate::llm::ChunkMessage::BackgroundJobEvent { .. } => {} crate::llm::ChunkMessage::End => { println!(); play_resolved_sound(&sounds, crate::sound::SoundEvent::Complete); @@ -705,6 +711,90 @@ enum Command { /// Target version (e.g. `0.0.12`) or `latest` target: Option, }, + + /// Manage survive-quit background jobs (list / logs / stop) + Jobs { + #[command(subcommand)] + command: JobsCommand, + }, + + /// Periodic cleanup tasks (jobs GC today; workspaces later) + Maintenance { + #[command(subcommand)] + command: MaintenanceCommand, + }, +} + +#[derive(Subcommand, Debug)] +enum JobsCommand { + /// List background jobs (default: current project only) + List { + /// Show jobs from all projects + #[arg(long)] + all: bool, + /// Human-friendly table (future: interactive TUI picker; for now just a pretty table) + #[arg(short = 'i', long)] + interactive: bool, + }, + /// Print a job's output.log + Logs { + /// Job id (e.g. job_01HXYZ…) + id: String, + /// Follow new output (like tail -f) + #[arg(long)] + follow: bool, + /// Number of trailing lines to print + #[arg(long, default_value_t = 200)] + tail: usize, + }, + /// Stop a background job (kill process group + update ledger) + Stop { + /// Job id + id: String, + }, + /// Stop all running jobs (default: current project only) + StopAll { + /// Stop running jobs from every project + #[arg(long)] + all: bool, + }, + /// Restart a background job (same id / command / cwd) + Restart { + /// Job id + id: String, + }, + /// Remove finished background jobs + /// + /// Scope: default = current session; `--all` = current project; `--global` = everything. + Clean { + /// Clean finished jobs for the current project (all sessions) + #[arg(long)] + all: bool, + /// Clean finished jobs across every project + #[arg(long)] + global: bool, + /// Age threshold (e.g. 7d, 24h, 30m). Ignored when --all/--global. + #[arg(long, default_value = "7d")] + older_than: String, + /// Report what would be removed without deleting + #[arg(long)] + dry_run: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum MaintenanceCommand { + /// Run registered maintenance tasks + Run { + /// Only run this task id (e.g. jobs) + #[arg(long)] + only: Option, + /// Report without deleting + #[arg(long)] + dry_run: bool, + }, + /// List registered maintenance tasks + List, } fn is_completion_help(args: &[String]) -> bool { @@ -844,6 +934,54 @@ async fn main() -> Result<()> { Some(Command::Upgrade { target }) => { return crate::upgrade::upgrade(target.as_deref()); } + Some(Command::Jobs { command }) => { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + match command { + JobsCommand::List { all, interactive } => { + crate::jobs::cli::run_list(crate::jobs::cli::ListOpts { + all: *all, + interactive: *interactive, + cwd, + })?; + } + JobsCommand::Logs { id, follow, tail } => { + crate::jobs::cli::run_logs(crate::jobs::cli::LogsOpts { + id: id.clone(), + follow: *follow, + tail: *tail, + })?; + } + JobsCommand::Stop { id } => { + crate::jobs::cli::run_stop(id)?; + } + JobsCommand::StopAll { all } => { + crate::jobs::cli::run_stop_all(*all, &cwd)?; + } + JobsCommand::Restart { id } => { + crate::jobs::cli::run_restart(id)?; + } + JobsCommand::Clean { + all, + global, + older_than, + dry_run, + } => { + crate::jobs::cli::run_clean(*all, *global, older_than, *dry_run, &cwd)?; + } + } + return Ok(()); + } + Some(Command::Maintenance { command }) => { + match command { + MaintenanceCommand::Run { only, dry_run } => { + crate::maintenance::cli_run(only.clone(), *dry_run)?; + } + MaintenanceCommand::List => { + crate::maintenance::cli_list()?; + } + } + return Ok(()); + } None => {} } diff --git a/src/maintenance/mod.rs b/src/maintenance/mod.rs new file mode 100644 index 00000000..84bffebc --- /dev/null +++ b/src/maintenance/mod.rs @@ -0,0 +1,228 @@ +//! Extensible maintenance tasks (job GC today; workspaces/caches later). +//! +//! Jobs register cleanup here — cleanup does **not** live only inside `src/jobs/`. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use anyhow::Result; + +pub mod tasks; + +/// A named maintenance task. Easy to add more later (workspaces, caches, …). +pub trait MaintenanceTask: Send + Sync { + fn id(&self) -> &'static str; + fn description(&self) -> &'static str; + /// When true, included in [`Maintenance::run_lazy`] / [`run_lazy_once`]. + fn auto_run(&self) -> bool { + false + } + /// Run the task. Should be fast / nonblocking enough for CLI + lazy UI paths. + fn run(&self, opts: &RunOpts) -> Result; +} + +#[derive(Debug, Clone)] +pub struct RunOpts { + /// If true, don't delete — just report what would happen. + pub dry_run: bool, + /// Only run this task id (None = all). + pub only: Option, +} + +impl Default for RunOpts { + fn default() -> Self { + Self { + dry_run: false, + only: None, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct TaskReport { + pub task_id: String, + pub removed: usize, + pub skipped: usize, + pub message: String, +} + +#[derive(Debug, Clone, Default)] +pub struct RunReport { + pub tasks: Vec, +} + +/// Registry of all maintenance tasks. +pub struct Maintenance { + pub(crate) tasks: Vec>, +} + +impl Maintenance { + pub fn with_defaults() -> Self { + let mut m = Self { tasks: vec![] }; + crate::maintenance::tasks::register_defaults(&mut m); + m + } + + pub fn register(&mut self, task: Box) { + self.tasks.push(task); + } + + pub fn list(&self) -> Vec<(&str, &str, bool)> { + self.tasks + .iter() + .map(|t| (t.id(), t.description(), t.auto_run())) + .collect() + } + + pub fn run(&self, opts: &RunOpts) -> Result { + let mut report = RunReport::default(); + for task in &self.tasks { + if let Some(ref only) = opts.only { + if task.id() != only.as_str() { + continue; + } + } + let mut tr = task.run(opts)?; + if tr.task_id.is_empty() { + tr.task_id = task.id().to_string(); + } + report.tasks.push(tr); + } + Ok(report) + } + + /// Lazy auto path: run cheap tasks that are safe to invoke often (e.g. job GC). + /// Must be fast and ignore errors. + pub fn run_lazy(&self) { + let opts = RunOpts { + dry_run: false, + only: None, + }; + for task in &self.tasks { + if !task.auto_run() { + continue; + } + let _ = task.run(&opts); + } + } +} + +static LAZY_RAN: AtomicBool = AtomicBool::new(false); + +/// Run auto-run maintenance tasks at most once per process. +pub fn run_lazy_once() { + if LAZY_RAN.swap(true, Ordering::Relaxed) { + return; + } + // Best-effort background GC — must not block UI/CLI list on FS deletes. + std::thread::Builder::new() + .name("crabcode-maintenance".into()) + .spawn(|| { + Maintenance::with_defaults().run_lazy(); + }) + .ok(); +} + +/// CLI: `crabcode maintenance run [--only …] [--dry-run]` +pub fn cli_run(only: Option, dry_run: bool) -> Result<()> { + let m = Maintenance::with_defaults(); + let report = m.run(&RunOpts { dry_run, only })?; + if report.tasks.is_empty() { + println!("(no matching maintenance tasks)"); + return Ok(()); + } + for t in report.tasks { + println!("{}: {}", t.task_id, t.message); + } + Ok(()) +} + +/// CLI: `crabcode maintenance list` +pub fn cli_list() -> Result<()> { + let m = Maintenance::with_defaults(); + for (id, desc, auto) in m.list() { + let auto_s = if auto { "auto" } else { "manual" }; + println!("{id}\t{auto_s}\t{desc}"); + } + Ok(()) +} + +/// Parse simple age strings: `7d`, `24h`, `30m`, or bare days as integer. +pub fn parse_age(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + anyhow::bail!("empty age"); + } + if let Ok(days) = s.parse::() { + return Ok(Duration::from_secs(days.saturating_mul(24 * 3600))); + } + let (num, unit) = s.split_at(s.len() - 1); + let n: u64 = num + .parse() + .map_err(|_| anyhow::anyhow!("invalid age '{s}' (expected Nd / Nh / Nm or days)"))?; + Ok(match unit { + "d" | "D" => Duration::from_secs(n.saturating_mul(24 * 3600)), + "h" | "H" => Duration::from_secs(n.saturating_mul(3600)), + "m" | "M" => Duration::from_secs(n.saturating_mul(60)), + _ => anyhow::bail!("invalid age unit in '{s}' (use d/h/m)"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jobs::ledger::{save_meta, JobMeta, JobStatus}; + use crate::jobs::test_env::TempState; + use chrono::Utc; + + #[test] + fn parse_age_supports_d_h_m_and_bare_days() { + assert_eq!(parse_age("7d").unwrap(), Duration::from_secs(7 * 24 * 3600)); + assert_eq!(parse_age("24h").unwrap(), Duration::from_secs(24 * 3600)); + assert_eq!(parse_age("30m").unwrap(), Duration::from_secs(30 * 60)); + assert_eq!(parse_age("7").unwrap(), Duration::from_secs(7 * 24 * 3600)); + } + + #[test] + fn maintenance_run_only_jobs() { + let _state = TempState::new(); + let old_ended = Utc::now() - chrono::Duration::days(10); + save_meta(&JobMeta { + id: "job_m".into(), + pid: 1, + pgid: None, + command: "echo".into(), + name: "m".into(), + workdir: "/tmp".into(), + session_id: None, + started_at: old_ended, + ended_at: Some(old_ended), + status: JobStatus::Exited, + exit_code: Some(0), + }) + .unwrap(); + + let m = Maintenance::with_defaults(); + let report = m + .run(&RunOpts { + dry_run: false, + only: Some("jobs".into()), + }) + .unwrap(); + assert_eq!(report.tasks.len(), 1); + assert_eq!(report.tasks[0].task_id, "jobs"); + assert_eq!(report.tasks[0].removed, 1); + } + + #[test] + fn run_lazy_once_returns_immediately() { + // Should not block the caller on FS deletes (spawned background thread). + let start = std::time::Instant::now(); + run_lazy_once(); + run_lazy_once(); // second call is a no-op + assert!( + start.elapsed() < std::time::Duration::from_millis(200), + "run_lazy_once blocked the caller" + ); + } +} diff --git a/src/maintenance/tasks.rs b/src/maintenance/tasks.rs new file mode 100644 index 00000000..d5e09978 --- /dev/null +++ b/src/maintenance/tasks.rs @@ -0,0 +1,87 @@ +//! Built-in maintenance task registrations (jobs today; workspaces later). + +use std::time::Duration; + +use anyhow::Result; + +use crate::jobs::ledger::CleanupScope; + +use super::{Maintenance, MaintenanceTask, RunOpts, TaskReport}; + +pub struct JobCleanup { + pub max_age: Duration, + pub scope: CleanupScope, +} + +impl MaintenanceTask for JobCleanup { + fn id(&self) -> &'static str { + "jobs" + } + + fn description(&self) -> &'static str { + "Remove finished (exited/killed/failed) background jobs older than max_age" + } + + fn auto_run(&self) -> bool { + // Auto/lazy path is always global age-based GC. + matches!(self.scope, CleanupScope::Global) + } + + fn run(&self, opts: &RunOpts) -> Result { + let (removed, skipped) = + crate::jobs::ledger::cleanup_finished(self.max_age, opts.dry_run, &self.scope)?; + let scope_label = match &self.scope { + CleanupScope::Session { .. } => "session", + CleanupScope::Project { .. } => "project", + CleanupScope::Global => "global", + }; + let message = if opts.dry_run { + format!("would remove {removed} finished job(s) ({scope_label}); skipped {skipped}") + } else { + format!("removed {removed} finished job(s) ({scope_label}); skipped {skipped}") + }; + Ok(TaskReport { + task_id: self.id().into(), + removed, + skipped, + message, + }) + } +} + +/// Job cleanup with an explicit max age + scope (used by `crabcode jobs clean`). +pub struct JobCleanupWithAge { + pub max_age: Duration, + pub scope: CleanupScope, +} + +impl MaintenanceTask for JobCleanupWithAge { + fn id(&self) -> &'static str { + "jobs" + } + + fn description(&self) -> &'static str { + "Remove finished background jobs older than the given max_age (scoped)" + } + + fn auto_run(&self) -> bool { + false + } + + fn run(&self, opts: &RunOpts) -> Result { + JobCleanup { + max_age: self.max_age, + scope: self.scope.clone(), + } + .run(opts) + } +} + +pub fn register_defaults(m: &mut Maintenance) { + m.register(Box::new(JobCleanup { + max_age: Duration::from_secs(7 * 24 * 3600), + scope: CleanupScope::Global, + })); + // Future: + // m.register(Box::new(WorkspaceCleanup { … })); +} diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index afb883e8..a03e06ae 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -181,7 +181,7 @@ Core Directives: - Minimize output tokens while maintaining quality - Avoid preamble/postamble unless asked - Batch independent tool calls in parallel -- Use dedicated tools over bash when possible +- Use dedicated tools over bash when possible; for long-running processes use bash mode=background + bash_output/bash_kill/bash_restart (short 2–4 word description) - Keep responses short (< 4 lines typically) - Answer directly without elaboration - No unnecessary explanations post-completion @@ -219,7 +219,7 @@ Security: - Explain bash commands that modify filesystem - Never introduce code that exposes secrets - Always use absolute paths -- Avoid interactive shell commands +- Avoid interactive shell commands; for servers/watchers use bash mode=background (bash_output/bash_kill/bash_restart), Esc minimizes interactive PTYs Your output will be displayed on a command line interface. Your responses should be short and concise (typically < 4 lines, excluding tool calls)."#.to_string() } @@ -329,7 +329,7 @@ Your output will be displayed on a command line interface. Your responses should format!( r#"Tool use: - Use the model's built-in tool/function calling mechanism (do not print tool calls as text). -- Prefer specialized tools over bash when possible (available: {names}). +- Prefer specialized tools over bash when possible (available: {names}). For long-running jobs use bash mode=background and manage with bash_output/bash_kill/bash_restart; interactive PTY Esc minimizes, ctrl+] stops. - After tool results are returned, use them to answer. "# ) diff --git a/src/tools/aisdk_bridge.rs b/src/tools/aisdk_bridge.rs index d074fd8d..39ce65b5 100644 --- a/src/tools/aisdk_bridge.rs +++ b/src/tools/aisdk_bridge.rs @@ -1,9 +1,10 @@ use crate::aisdk::core::tools::{ToolExecute, ToolOutput}; use crate::aisdk::core::Tool; -use crate::tools::{ToolContext, ToolRegistry}; +use crate::tools::{ProcessRegistry, ToolContext, ToolRegistry}; use schemars::Schema; use serde_json::Value; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; use std::time::Instant; use tokio_util::sync::CancellationToken; @@ -25,6 +26,7 @@ pub async fn convert_to_aisdk_tools( message_id: Option, supports_image_input: bool, cancel_token: CancellationToken, + process_registry: Option>, ) -> Vec { let mut aisdk_tools = Vec::new(); let tools = registry.list().await; @@ -47,6 +49,7 @@ pub async fn convert_to_aisdk_tools( let session_id = session_id.clone(); let message_id = message_id.clone(); let cancel_token = cancel_token.clone(); + let process_registry = process_registry.clone(); let execute = ToolExecute::new(move |input: Value| { let tool_id = tool_id.clone(); @@ -60,6 +63,7 @@ pub async fn convert_to_aisdk_tools( let session_id = session_id.clone(); let message_id = message_id.clone(); let cancel_token = cancel_token.clone(); + let process_registry = process_registry.clone(); let supports_image_input = supports_image_input; async move { @@ -160,7 +164,7 @@ pub async fn convert_to_aisdk_tools( return Err(err); } - let ctx = ToolContext::from_cancel_token( + let mut ctx = ToolContext::from_cancel_token( session_id.clone().unwrap_or_else(|| "session".to_string()), message_id.clone().unwrap_or_else(|| "message".to_string()), agent_mode.clone(), @@ -168,6 +172,9 @@ pub async fn convert_to_aisdk_tools( ) .with_call_id(call_id.clone()) .with_workdir(permissions.workdir().to_path_buf()); + if let Some(ref process_registry) = process_registry { + ctx = ctx.with_process_registry(process_registry.clone()); + } let tool_result = handler .execute(input, &ctx) diff --git a/src/tools/bash.rs b/src/tools/bash.rs index 626c8660..150e224e 100644 --- a/src/tools/bash.rs +++ b/src/tools/bash.rs @@ -1,10 +1,15 @@ +use crate::llm::ChunkSender; +use crate::tools::process_registry::{JobStatus, ProcessRegistry}; +use crate::tools::terminal_session::TerminalSessionTool; use crate::tools::{ get_integer_param, get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, ToolError, ToolHandler, ToolResult, }; use async_trait::async_trait; use serde_json::Value; +use std::path::PathBuf; use std::process::Stdio; +use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, BufReader}; use tokio::process::Command; @@ -17,129 +22,88 @@ const DEFAULT_TIMEOUT_SECONDS: u64 = 120; const MAX_OUTPUT_BYTES: usize = 20_000; const READ_CHUNK_SIZE: usize = 4_096; -pub struct BashTool; - -impl BashTool { - pub fn new() -> Self { - Self - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BashMode { + Foreground, + Background, + Interactive, } -#[cfg(unix)] -fn kill_process_group(pid: Option) { - if let Some(pid) = pid { - unsafe { - let _ = libc::killpg(pid as i32, libc::SIGKILL); +impl BashMode { + fn parse(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "" | "foreground" => Ok(Self::Foreground), + "background" => Ok(Self::Background), + "interactive" => Ok(Self::Interactive), + other => Err(ToolError::Validation(format!( + "Unknown bash mode '{other}'. Expected foreground|background|interactive" + ))), } } -} - -#[cfg(unix)] -async fn terminate_child(child: &mut tokio::process::Child) { - kill_process_group(child.id()); - let _ = child.kill().await; -} -#[cfg(not(unix))] -fn kill_process_group(_pid: Option) {} - -#[cfg(not(unix))] -async fn terminate_child(child: &mut tokio::process::Child) { - let _ = child.kill().await; -} - -async fn drain_reader(mut reader: impl tokio::io::AsyncRead + Unpin) { - let mut buffer = vec![0u8; READ_CHUNK_SIZE]; - loop { - match reader.read(&mut buffer).await { - Ok(0) | Err(_) => break, - Ok(_) => {} + fn as_str(self) -> &'static str { + match self { + Self::Foreground => "foreground", + Self::Background => "background", + Self::Interactive => "interactive", } } } -fn append_capped(buffer: &mut Vec, chunk: &[u8], truncated: &mut bool) { - if *truncated { - return; - } - let remaining = MAX_OUTPUT_BYTES.saturating_sub(buffer.len()); - if remaining == 0 { - *truncated = true; - return; - } - let take = chunk.len().min(remaining); - buffer.extend_from_slice(&chunk[..take]); - if take < chunk.len() { - *truncated = true; - } +pub struct BashTool { + chunk_tx: Option, + registry: Option>, } -#[async_trait] -impl ToolHandler for BashTool { - fn definition(&self) -> Tool { - Tool { - id: "bash".to_string(), - description: "Execute non-interactive shell commands with a timeout and captured output. Stdin is closed, so commands that prompt for input will receive EOF; use `terminal_session` when a TTY or user interaction is required." - .to_string(), - parameters: vec![ - ParameterSchema { - name: "command".to_string(), - description: "Command to execute".to_string(), - required: true, - param_type: ParameterType::String, - }, - ParameterSchema { - name: "timeout".to_string(), - description: "Timeout in seconds (default: 120)".to_string(), - required: false, - param_type: ParameterType::Integer, - }, - ParameterSchema { - name: "workdir".to_string(), - description: "Working directory for the command".to_string(), - required: false, - param_type: ParameterType::String, - }, - ParameterSchema { - name: "description".to_string(), - description: "Human-readable description of what the command does".to_string(), - required: false, - param_type: ParameterType::String, - }, - ], - input_schema: None, +impl BashTool { + pub fn new() -> Self { + Self { + chunk_tx: None, + registry: None, } } - fn validate(&self, params: &Value) -> Result<(), ToolError> { - validate_required(params, &["command"]) + pub fn with_sender(mut self, sender: ChunkSender) -> Self { + self.chunk_tx = Some(sender); + self } - async fn execute(&self, params: Value, ctx: &ToolContext) -> Result { - let command_str = get_string_param(¶ms, "command") - .ok_or_else(|| ToolError::Validation("command is required".to_string()))?; - - let timeout_seconds = get_integer_param(¶ms, "timeout") - .map(|v| { - if v <= 0 { - DEFAULT_TIMEOUT_SECONDS - } else { - v as u64 - } - }) - .unwrap_or(DEFAULT_TIMEOUT_SECONDS); + pub fn with_sender_opt(mut self, sender: Option) -> Self { + self.chunk_tx = sender; + self + } - let workdir = - get_string_param(¶ms, "path").or_else(|| get_string_param(¶ms, "workdir")); + pub fn with_registry(mut self, registry: Arc) -> Self { + self.registry = Some(registry); + self + } - let description = - get_string_param(¶ms, "description").unwrap_or_else(|| command_str.clone()); + fn resolve_registry<'a>(&'a self, ctx: &'a ToolContext) -> Option<&'a Arc> { + self.registry.as_ref().or(ctx.process_registry.as_ref()) + } - let mut cmd = Command::new("bash"); - cmd.arg("-c").arg(&command_str); + async fn execute_foreground( + &self, + command_str: String, + description: String, + workdir: Option, + timeout_seconds: u64, + ctx: &ToolContext, + ) -> Result { + let mut cmd = if cfg!(windows) { + let mut c = Command::new("cmd"); + c.arg("/C").arg(&command_str); + c + } else { + let mut c = Command::new("bash"); + c.arg("-c").arg(&command_str); + c + }; if let Some(dir) = workdir { cmd.current_dir(dir); + } else { + cmd.current_dir(ctx.workdir()); } cmd.stdin(Stdio::null()); @@ -284,9 +248,296 @@ impl ToolHandler for BashTool { Ok( ToolResult::new(format!("Bash: {}", description), final_output) .with_metadata("exit_code", serde_json::json!(exit_code)) - .with_metadata("command", serde_json::json!(command_str)), + .with_metadata("command", serde_json::json!(command_str)) + .with_metadata("mode", serde_json::json!(BashMode::Foreground.as_str())), + ) + } + + async fn execute_background( + &self, + command_str: String, + description: String, + workdir: PathBuf, + ctx: &ToolContext, + ) -> Result { + let registry = self.resolve_registry(ctx).ok_or_else(|| { + ToolError::Execution( + "Background mode requires a ProcessRegistry (not available in this context)" + .to_string(), + ) + })?; + + let session_id = (!ctx.session_id.is_empty()).then(|| ctx.session_id.clone()); + let spawned = registry + .spawn_background( + command_str.clone(), + description.clone(), + &workdir, + session_id, + ctx.cancel_token.child_token(), + ) + .await + .map_err(ToolError::Execution)?; + + let output = format!( + "Background job started.\n\ +task_id: {}\n\ +command: {}\n\ +workdir: {}\n\n\ +Poll output with bash_output (task_id=\"{}\").\n\ +Kill with bash_kill (task_id=\"{}\").", + spawned.task_id, + command_str, + workdir.display(), + spawned.task_id, + spawned.task_id + ); + + Ok( + ToolResult::new(format!("Background: {}", description), output) + .with_metadata("task_id", serde_json::json!(spawned.task_id)) + .with_metadata("mode", serde_json::json!(BashMode::Background.as_str())) + .with_metadata("command", serde_json::json!(command_str)) + .with_metadata("description", serde_json::json!(description)) + .with_metadata("workdir", serde_json::json!(workdir.display().to_string())), ) } + + async fn execute_interactive( + &self, + command_str: String, + description: String, + workdir: PathBuf, + ctx: &ToolContext, + ) -> Result { + if self.chunk_tx.is_none() { + return Err(ToolError::Execution( + "Interactive bash requires a live UI session (chunk sender unavailable)" + .to_string(), + )); + } + + let registry = self.resolve_registry(ctx).cloned(); + let job_id = if let Some(ref registry) = registry { + Some( + registry + .register_interactive(command_str.clone(), description.clone(), &workdir) + .await, + ) + } else { + None + }; + + // Reuse the same PTY + UI dialog path as terminal_session. + // Pass job_id so terminal_session does not double-register. + let term = TerminalSessionTool::new().with_sender_opt(self.chunk_tx.clone()); + let mut params = serde_json::json!({ + "command": command_str, + "workdir": workdir.display().to_string(), + "description": description, + }); + if let Some(ref id) = job_id { + params["job_id"] = serde_json::json!(id); + } + + let mut result = match term.execute(params, ctx).await { + Ok(mut result) => { + if let (Some(registry), Some(job_id)) = (registry.as_ref(), job_id.as_ref()) { + let stopped = result + .metadata + .get("stopped_by_user") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let exit_code = result + .metadata + .get("exit_code") + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + let status = if stopped { + JobStatus::Killed + } else { + JobStatus::Exited + }; + registry + .mark_interactive_status(job_id, status, exit_code) + .await; + result = result.with_metadata("task_id", serde_json::json!(job_id)); + } + result.with_metadata("mode", serde_json::json!(BashMode::Interactive.as_str())) + } + Err(err) => { + if let (Some(registry), Some(job_id)) = (registry.as_ref(), job_id.as_ref()) { + registry + .mark_interactive_status(job_id, JobStatus::Failed, None) + .await; + } + return Err(err); + } + }; + + result.title = result + .title + .replacen("Terminal session:", "Interactive:", 1); + Ok(result) + } +} + +#[cfg(unix)] +fn kill_process_group(pid: Option) { + if let Some(pid) = pid { + unsafe { + let _ = libc::killpg(pid as i32, libc::SIGKILL); + } + } +} + +#[cfg(unix)] +async fn terminate_child(child: &mut tokio::process::Child) { + kill_process_group(child.id()); + let _ = child.kill().await; +} + +#[cfg(not(unix))] +fn kill_process_group(_pid: Option) {} + +#[cfg(not(unix))] +async fn terminate_child(child: &mut tokio::process::Child) { + let _ = child.kill().await; +} + +async fn drain_reader(mut reader: impl tokio::io::AsyncRead + Unpin) { + let mut buffer = vec![0u8; READ_CHUNK_SIZE]; + loop { + match reader.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } +} + +fn append_capped(buffer: &mut Vec, chunk: &[u8], truncated: &mut bool) { + if *truncated { + return; + } + let remaining = MAX_OUTPUT_BYTES.saturating_sub(buffer.len()); + if remaining == 0 { + *truncated = true; + return; + } + let take = chunk.len().min(remaining); + buffer.extend_from_slice(&chunk[..take]); + if take < chunk.len() { + *truncated = true; + } +} + +#[async_trait] +impl ToolHandler for BashTool { + fn definition(&self) -> Tool { + Tool { + id: "bash".to_string(), + description: "Run shell commands with hybrid modes:\n\ +- mode=\"foreground\" (default): short non-interactive commands with timeout; stdin is closed (EOF on prompts).\n\ +- mode=\"background\": long-running servers/watchers (e.g. bun dev, cargo watch). NEVER use interactive for these. Returns a task_id immediately; manage with bash_output / bash_kill / bash_restart. Background jobs survive crabcode quit — humans can inspect them with `crabcode jobs list|logs|stop|restart`.\n\ +- mode=\"interactive\": only when the user must type (npx prompts, ssh, password entry, pagers). Opens an embedded TTY dialog (Esc minimizes / parks the session; ctrl+] stops).\n\ +Prefer bash over the legacy terminal_session alias. Use bash_output/bash_kill/bash_restart to manage background jobs. Open the jobs list via WhichKey `j` (ctrl+x then j), ctrl+p \"Background Jobs\", or the bottom-right jobs chip.\n\ +For mode=background, pass description as a short 2–4 word name (e.g. \"Dev server\")." + .to_string(), + parameters: vec![ + ParameterSchema { + name: "command".to_string(), + description: "Command to execute".to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "mode".to_string(), + description: "Run mode: foreground (default) | background | interactive" + .to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "timeout".to_string(), + description: "Timeout in seconds for foreground mode (default: 120)" + .to_string(), + required: false, + param_type: ParameterType::Integer, + }, + ParameterSchema { + name: "workdir".to_string(), + description: "Working directory for the command".to_string(), + required: false, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "description".to_string(), + description: "Short 2–4 word job name for Jobs UI / `crabcode jobs list` (e.g. \"Dev server\"). Especially useful for mode=background.".to_string(), + required: false, + param_type: ParameterType::String, + }, + ], + input_schema: None, + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["command"])?; + if let Some(mode) = get_string_param(params, "mode") { + BashMode::parse(&mode)?; + } + Ok(()) + } + + async fn execute(&self, params: Value, ctx: &ToolContext) -> Result { + let command_str = get_string_param(¶ms, "command") + .ok_or_else(|| ToolError::Validation("command is required".to_string()))?; + + let mode = BashMode::parse( + &get_string_param(¶ms, "mode").unwrap_or_else(|| "foreground".to_string()), + )?; + + let timeout_seconds = get_integer_param(¶ms, "timeout") + .map(|v| { + if v <= 0 { + DEFAULT_TIMEOUT_SECONDS + } else { + v as u64 + } + }) + .unwrap_or(DEFAULT_TIMEOUT_SECONDS); + + let workdir_param = + get_string_param(¶ms, "path").or_else(|| get_string_param(¶ms, "workdir")); + + let description = + get_string_param(¶ms, "description").unwrap_or_else(|| command_str.clone()); + + let workdir = workdir_param + .map(PathBuf::from) + .unwrap_or_else(|| ctx.workdir().to_path_buf()); + + match mode { + BashMode::Foreground => { + self.execute_foreground( + command_str, + description, + Some(workdir.display().to_string()), + timeout_seconds, + ctx, + ) + .await + } + BashMode::Background => { + self.execute_background(command_str, description, workdir, ctx) + .await + } + BashMode::Interactive => { + self.execute_interactive(command_str, description, workdir, ctx) + .await + } + } + } } #[cfg(test)] @@ -303,6 +554,30 @@ mod tests { assert!(truncated); } + #[test] + fn mode_validation_rejects_unknown() { + let tool = BashTool::new(); + let err = tool + .validate(&serde_json::json!({ + "command": "echo hi", + "mode": "wat" + })) + .expect_err("unknown mode should fail"); + assert!(err.to_string().contains("Unknown bash mode")); + } + + #[test] + fn mode_validation_accepts_known() { + let tool = BashTool::new(); + for mode in ["foreground", "background", "interactive", "BACKGROUND"] { + tool.validate(&serde_json::json!({ + "command": "echo hi", + "mode": mode + })) + .unwrap_or_else(|_| panic!("mode {mode} should be valid")); + } + } + #[tokio::test] async fn interactive_read_receives_eof_instead_of_hanging() { let ctx = diff --git a/src/tools/bash_kill.rs b/src/tools/bash_kill.rs new file mode 100644 index 00000000..593456c1 --- /dev/null +++ b/src/tools/bash_kill.rs @@ -0,0 +1,66 @@ +use crate::tools::process_registry::ProcessRegistry; +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; + +pub struct BashKillTool { + registry: Arc, +} + +impl BashKillTool { + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + pub fn with_registry(mut self, registry: Arc) -> Self { + self.registry = registry; + self + } +} + +#[async_trait] +impl ToolHandler for BashKillTool { + fn definition(&self) -> Tool { + Tool { + id: "bash_kill".to_string(), + description: "Kill a background bash job previously started with bash \ +mode=\"background\". Prefer this over killing via shell when you have a task_id. \ +Background jobs survive crabcode quit — humans can also stop them with `crabcode jobs stop `." + .to_string(), + parameters: vec![ParameterSchema { + name: "task_id".to_string(), + description: "Task id returned by bash in background mode (e.g. job_01HXYZ…)" + .to_string(), + required: true, + param_type: ParameterType::String, + }], + input_schema: None, + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["task_id"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + self.validate(¶ms)?; + let task_id = get_string_param(¶ms, "task_id") + .ok_or_else(|| ToolError::Validation("Missing required parameter: task_id".into()))?; + + self.registry + .kill(&task_id) + .await + .map_err(ToolError::Execution)?; + + Ok(ToolResult::new( + format!("Killed: {task_id}"), + format!("Killed task {task_id}"), + ) + .with_metadata("task_id", serde_json::json!(task_id)) + .with_metadata("status", serde_json::json!("killed"))) + } +} diff --git a/src/tools/bash_output.rs b/src/tools/bash_output.rs new file mode 100644 index 00000000..b95c400f --- /dev/null +++ b/src/tools/bash_output.rs @@ -0,0 +1,122 @@ +use crate::tools::process_registry::ProcessRegistry; +use crate::tools::{ + get_bool_param, get_integer_param, get_string_param, validate_required, ParameterSchema, + ParameterType, Tool, ToolContext, ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; + +pub struct BashOutputTool { + registry: Arc, +} + +impl BashOutputTool { + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + pub fn with_registry(mut self, registry: Arc) -> Self { + self.registry = registry; + self + } +} + +#[async_trait] +impl ToolHandler for BashOutputTool { + fn definition(&self) -> Tool { + Tool { + id: "bash_output".to_string(), + description: "Read output from a background/interactive bash job started with \ +bash mode=\"background\". By default returns bytes since the last successful read \ +(Grok-style since-last). Pass offset=0 to re-read from the start of the retained log. \ +Optionally wait for new output or process exit via wait/timeout. Jobs survive crabcode \ +quit; humans can also use `crabcode jobs logs `." + .to_string(), + parameters: vec![ + ParameterSchema { + name: "task_id".to_string(), + description: "Task id returned by bash in background mode (e.g. job_01HXYZ…)" + .to_string(), + required: true, + param_type: ParameterType::String, + }, + ParameterSchema { + name: "wait".to_string(), + description: "If true, wait up to ~30s for new output or process exit \ +(ignored if timeout is set)" + .to_string(), + required: false, + param_type: ParameterType::Boolean, + }, + ParameterSchema { + name: "timeout".to_string(), + description: "Milliseconds to wait for new output or process exit \ +before returning current state" + .to_string(), + required: false, + param_type: ParameterType::Integer, + }, + ParameterSchema { + name: "offset".to_string(), + description: "Absolute byte offset into the job's logical output stream. \ +Omit for since-last semantics; pass 0 to read from the start of the retained buffer." + .to_string(), + required: false, + param_type: ParameterType::Integer, + }, + ], + input_schema: None, + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["task_id"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + self.validate(¶ms)?; + let task_id = get_string_param(¶ms, "task_id") + .ok_or_else(|| ToolError::Validation("Missing required parameter: task_id".into()))?; + + let wait_ms = if let Some(timeout) = get_integer_param(¶ms, "timeout") { + if timeout < 0 { + return Err(ToolError::Validation( + "timeout must be a non-negative integer (milliseconds)".into(), + )); + } + Some(timeout as u64) + } else if get_bool_param(¶ms, "wait", false) { + Some(30_000) + } else { + None + }; + + let since_byte = + get_integer_param(¶ms, "offset").map(|v| if v < 0 { 0u64 } else { v as u64 }); + + let out = self + .registry + .output(&task_id, wait_ms, since_byte) + .await + .map_err(ToolError::Execution)?; + + let text = if out.text.is_empty() { + if out.status.is_terminal() { + "(no new output; process finished)".to_string() + } else { + "(no new output yet)".to_string() + } + } else { + out.text + }; + + Ok(ToolResult::new(format!("Output: {task_id}"), text) + .with_metadata("task_id", serde_json::json!(task_id)) + .with_metadata("status", serde_json::json!(out.status.as_str())) + .with_metadata("exit_code", serde_json::json!(out.exit_code)) + .with_metadata("bytes_total", serde_json::json!(out.bytes_total)) + .with_metadata("truncated", serde_json::json!(out.truncated)) + .with_metadata("next_offset", serde_json::json!(out.next_offset))) + } +} diff --git a/src/tools/bash_restart.rs b/src/tools/bash_restart.rs new file mode 100644 index 00000000..13f91a41 --- /dev/null +++ b/src/tools/bash_restart.rs @@ -0,0 +1,72 @@ +use crate::tools::process_registry::ProcessRegistry; +use crate::tools::{ + get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, + ToolError, ToolHandler, ToolResult, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; + +pub struct BashRestartTool { + registry: Arc, +} + +impl BashRestartTool { + pub fn new(registry: Arc) -> Self { + Self { registry } + } + + pub fn with_registry(mut self, registry: Arc) -> Self { + self.registry = registry; + self + } +} + +#[async_trait] +impl ToolHandler for BashRestartTool { + fn definition(&self) -> Tool { + Tool { + id: "bash_restart".to_string(), + description: "Restart a background job by id (same command/cwd). Use when a server \ +died or needs reload. Reuses the same task_id and appends a restart marker to the log. Prefer \ +this over killing + re-running when you have a task_id." + .to_string(), + parameters: vec![ParameterSchema { + name: "task_id".to_string(), + description: "Task id returned by bash in background mode (e.g. job_01HXYZ…)" + .to_string(), + required: true, + param_type: ParameterType::String, + }], + input_schema: None, + } + } + + fn validate(&self, params: &Value) -> Result<(), ToolError> { + validate_required(params, &["task_id"]) + } + + async fn execute(&self, params: Value, _ctx: &ToolContext) -> Result { + self.validate(¶ms)?; + let task_id = get_string_param(¶ms, "task_id") + .ok_or_else(|| ToolError::Validation("Missing required parameter: task_id".into()))?; + + let meta = self + .registry + .restart(&task_id) + .await + .map_err(ToolError::Execution)?; + + Ok(ToolResult::new( + format!( + "Restarted: {} (pid={}, name={})", + meta.id, meta.pid, meta.name + ), + format!("Restarted task {}", meta.id), + ) + .with_metadata("task_id", serde_json::json!(meta.id)) + .with_metadata("pid", serde_json::json!(meta.pid)) + .with_metadata("name", serde_json::json!(meta.name)) + .with_metadata("status", serde_json::json!(meta.status.as_str()))) + } +} diff --git a/src/tools/context.rs b/src/tools/context.rs index 317e6143..fb69e79d 100644 --- a/src/tools/context.rs +++ b/src/tools/context.rs @@ -1,4 +1,6 @@ +use crate::tools::process_registry::ProcessRegistry; use std::path::{Path, PathBuf}; +use std::sync::Arc; pub struct ToolContext { pub session_id: String, @@ -9,6 +11,7 @@ pub struct ToolContext { pub call_id: Option, pub extra: Option, workdir: PathBuf, + pub process_registry: Option>, } impl ToolContext { @@ -27,6 +30,7 @@ impl ToolContext { call_id: None, extra: None, workdir: crate::utils::cwd::current_dir_or_dot(), + process_registry: None, } } @@ -46,6 +50,7 @@ impl ToolContext { call_id: None, extra: None, workdir: crate::utils::cwd::current_dir_or_dot(), + process_registry: None, } } @@ -64,6 +69,11 @@ impl ToolContext { self } + pub fn with_process_registry(mut self, registry: Arc) -> Self { + self.process_registry = Some(registry); + self + } + pub fn workdir(&self) -> &Path { &self.workdir } diff --git a/src/tools/init.rs b/src/tools/init.rs index 9a3b4505..403e6979 100644 --- a/src/tools/init.rs +++ b/src/tools/init.rs @@ -1,7 +1,8 @@ use crate::tools::{ fs::{GlobTool, GrepTool, ListTool, ReadTool, ViewImageTool, WriteFilesTool, WriteTool}, - ApplyPatchTool, BashTool, EditTool, QuestionTool, SkillTool, TaskTool, TerminalSessionTool, - ToolPermissions, ToolRegistry, UpdatePlanTool, WebfetchTool, WebsearchTool, + ApplyPatchTool, BashKillTool, BashOutputTool, BashRestartTool, BashTool, EditTool, + ProcessRegistry, QuestionTool, SkillTool, TaskTool, TerminalSessionTool, ToolPermissions, + ToolRegistry, UpdatePlanTool, WebfetchTool, WebsearchTool, }; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -12,6 +13,7 @@ pub async fn initialize_tool_registry() -> ToolRegistry { &crate::config::configuration::WebsearchConfig::default(), &crate::config::configuration::McpConfig::default(), ".", + Arc::new(ProcessRegistry::new()), ) .await } @@ -21,6 +23,7 @@ pub async fn initialize_tool_registry_with_config( websearch_config: &crate::config::configuration::WebsearchConfig, mcp_config: &crate::config::configuration::McpConfig, workspace: impl Into, + process_registry: Arc, ) -> ToolRegistry { let registry = ToolRegistry::new(); @@ -32,7 +35,20 @@ pub async fn initialize_tool_registry_with_config( registry.register(Arc::new(ApplyPatchTool::new())).await; registry.register(Arc::new(WriteTool::new())).await; registry.register(Arc::new(WriteFilesTool::new())).await; - registry.register(Arc::new(BashTool::new())).await; + registry + .register(Arc::new( + BashTool::new().with_registry(process_registry.clone()), + )) + .await; + registry + .register(Arc::new(BashOutputTool::new(process_registry.clone()))) + .await; + registry + .register(Arc::new(BashKillTool::new(process_registry.clone()))) + .await; + registry + .register(Arc::new(BashRestartTool::new(process_registry.clone()))) + .await; registry.register(Arc::new(EditTool::new())).await; registry.register(Arc::new(SkillTool::new())).await; registry.register(Arc::new(WebfetchTool::new())).await; @@ -100,7 +116,26 @@ pub async fn register_dynamic_tools( permissions: ToolPermissions, agent_registry: crate::agent::definition::AgentRegistry, cancel_token: CancellationToken, + process_registry: Arc, ) { + // Keep bash tools wired to the shared registry + optional chunk sender for interactive. + registry + .register(Arc::new( + BashTool::new() + .with_sender_opt(sender.clone()) + .with_registry(process_registry.clone()), + )) + .await; + registry + .register(Arc::new(BashOutputTool::new(process_registry.clone()))) + .await; + registry + .register(Arc::new(BashKillTool::new(process_registry.clone()))) + .await; + registry + .register(Arc::new(BashRestartTool::new(process_registry.clone()))) + .await; + registry .register(Arc::new( QuestionTool::new().with_sender_opt(sender.clone()), @@ -115,8 +150,13 @@ pub async fn register_dynamic_tools( )) .await; + // Keep terminal_session as a thin interactive alias for back-compat. registry - .register(Arc::new(TerminalSessionTool::new().with_sender_opt(sender))) + .register(Arc::new( + TerminalSessionTool::new() + .with_sender_opt(sender) + .with_registry(process_registry), + )) .await; } @@ -125,9 +165,25 @@ pub async fn initialize_tool_registry_with_dynamic( permissions: ToolPermissions, agent_registry: crate::agent::definition::AgentRegistry, cancel_token: CancellationToken, + process_registry: Arc, ) -> ToolRegistry { - let registry = initialize_tool_registry().await; - register_dynamic_tools(®istry, sender, permissions, agent_registry, cancel_token).await; + let registry = initialize_tool_registry_with_config( + None, + &crate::config::configuration::WebsearchConfig::default(), + &crate::config::configuration::McpConfig::default(), + ".", + process_registry.clone(), + ) + .await; + register_dynamic_tools( + ®istry, + sender, + permissions, + agent_registry, + cancel_token, + process_registry, + ) + .await; registry } @@ -140,15 +196,25 @@ pub async fn initialize_tool_registry_with_dynamic_config( websearch_config: &crate::config::configuration::WebsearchConfig, mcp_config: &crate::config::configuration::McpConfig, workspace: impl Into, + process_registry: Arc, ) -> ToolRegistry { let registry = initialize_tool_registry_with_config( provider_name, websearch_config, mcp_config, workspace, + process_registry.clone(), + ) + .await; + register_dynamic_tools( + ®istry, + sender, + permissions, + agent_registry, + cancel_token, + process_registry, ) .await; - register_dynamic_tools(®istry, sender, permissions, agent_registry, cancel_token).await; registry } @@ -179,12 +245,16 @@ mod tests { ToolPermissions::new("."), crate::agent::definition::AgentRegistry::default(), CancellationToken::new(), + Arc::new(ProcessRegistry::new()), ) .await; assert!(registry.get("question").await.is_some()); assert!(registry.get("task").await.is_some()); assert!(registry.get("terminal_session").await.is_some()); + assert!(registry.get("bash_output").await.is_some()); + assert!(registry.get("bash_kill").await.is_some()); + assert!(registry.get("bash_restart").await.is_some()); } #[tokio::test] @@ -195,6 +265,7 @@ mod tests { permissions.clone(), crate::agent::definition::AgentRegistry::default(), CancellationToken::new(), + Arc::new(ProcessRegistry::new()), ) .await; let scoped = scope_tool_registry_for_agent(®istry, &permissions, "plan").await; @@ -202,6 +273,9 @@ mod tests { assert!(scoped.get("read").await.is_some()); assert!(scoped.get("task").await.is_some()); assert!(scoped.get("bash").await.is_none()); + assert!(scoped.get("bash_output").await.is_none()); + assert!(scoped.get("bash_kill").await.is_none()); + assert!(scoped.get("bash_restart").await.is_none()); assert!(scoped.get("terminal_session").await.is_none()); assert!(scoped.get("apply_patch").await.is_none()); assert!(scoped.get("write").await.is_none()); diff --git a/src/tools/mod.rs b/src/tools/mod.rs index c84a2861..9d31906a 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -3,6 +3,9 @@ use serde_json::Value; pub mod aisdk_bridge; pub mod bash; +pub mod bash_kill; +pub mod bash_output; +pub mod bash_restart; pub mod context; pub mod edit; pub mod fs; @@ -10,6 +13,7 @@ pub mod init; pub mod mutation; pub mod patch; pub mod permission; +pub mod process_registry; pub mod question; pub mod registry; pub mod skill; @@ -21,6 +25,9 @@ pub mod webfetch; pub mod websearch; pub use bash::BashTool; +pub use bash_kill::BashKillTool; +pub use bash_output::BashOutputTool; +pub use bash_restart::BashRestartTool; pub use context::ToolContext; pub use edit::EditTool; pub use init::{ @@ -33,6 +40,10 @@ pub use permission::{ PermissionPolicyAction, PermissionPrompt, PermissionResponse, PermissionRule, PermissionRules, ToolPermissions, }; +#[allow(unused_imports)] +pub use process_registry::{ + JobKind, JobOutput, JobStatus, ProcessJobSnapshot, ProcessRegistry, SpawnedJob, +}; pub use question::QuestionTool; pub use registry::ToolRegistry; pub use skill::SkillTool; diff --git a/src/tools/permission.rs b/src/tools/permission.rs index 46711e99..90d8ff07 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -54,7 +54,9 @@ impl PermissionAction { "list" => Self::List, "glob" => Self::Glob, "grep" => Self::Grep, - "bash" | "terminal_session" => Self::Bash, + "bash" | "bash_output" | "bash_kill" | "bash_restart" | "terminal_session" => { + Self::Bash + } _ => Self::Unknown, } } @@ -199,7 +201,15 @@ impl AgentToolPolicies { // policies above can still opt specific tools back in. return !matches!( tool.as_str(), - "bash" | "terminal_session" | "write" | "write_files" | "edit" | "apply_patch" + "bash" + | "bash_output" + | "bash_kill" + | "bash_restart" + | "terminal_session" + | "write" + | "write_files" + | "edit" + | "apply_patch" ); } @@ -757,7 +767,7 @@ fn permission_key_for_tool_id(tool_id: &str) -> String { match tool_id.trim().to_ascii_lowercase().as_str() { "write" | "write_files" | "edit" | "apply_patch" => "edit".to_string(), "read" | "view_image" => "read".to_string(), - "terminal_session" => "bash".to_string(), + "terminal_session" | "bash_output" | "bash_kill" | "bash_restart" => "bash".to_string(), other => other.to_string(), } } @@ -773,7 +783,7 @@ fn permission_patterns_for_tool( let mut patterns = Vec::new(); match tool_id { - "bash" | "terminal_session" => { + "bash" | "bash_output" | "bash_kill" | "bash_restart" | "terminal_session" => { if let Some(command) = command { push_nonempty(&mut patterns, command); } @@ -1190,6 +1200,8 @@ mod tests { assert!(policies.is_allowed("plan", "read")); assert!(policies.is_allowed("plan", "glob")); assert!(!policies.is_allowed("plan", "bash")); + assert!(!policies.is_allowed("plan", "bash_output")); + assert!(!policies.is_allowed("plan", "bash_kill")); assert!(!policies.is_allowed("plan", "terminal_session")); assert!(!policies.is_allowed("plan", "write")); assert!(!policies.is_allowed("plan", "write_files")); diff --git a/src/tools/process_registry.rs b/src/tools/process_registry.rs new file mode 100644 index 00000000..98ec85e7 --- /dev/null +++ b/src/tools/process_registry.rs @@ -0,0 +1,1180 @@ +use crate::jobs::ledger::{ + canonicalize_workdir, list_for_project, load_meta, refresh_if_dead, JobMeta, + JobStatus as LedgerStatus, +}; +use crate::jobs::spawn::{self as jobs_spawn, SpawnDetachedOpts}; +use crate::llm::{BackgroundJobEventKind, ChunkMessage, ChunkSender}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +const RING_CAPACITY: usize = 1024 * 1024; // ~1MB +const MAX_FINISHED_JOBS: usize = 50; +const OUTPUT_POLL_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JobKind { + Background, + Interactive, +} + +impl JobKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Background => "background", + Self::Interactive => "interactive", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JobStatus { + Running, + Exited, + Killed, + Failed, +} + +impl JobStatus { + pub fn as_str(self) -> &'static str { + match self { + JobStatus::Running => "running", + JobStatus::Exited => "exited", + JobStatus::Killed => "killed", + JobStatus::Failed => "failed", + } + } + + pub fn is_terminal(self) -> bool { + !matches!(self, Self::Running) + } +} + +impl From for JobStatus { + fn from(s: LedgerStatus) -> Self { + match s { + LedgerStatus::Running => Self::Running, + LedgerStatus::Exited => Self::Exited, + LedgerStatus::Killed => Self::Killed, + LedgerStatus::Failed => Self::Failed, + } + } +} + +#[derive(Debug, Clone)] +pub struct ProcessJobSnapshot { + pub id: String, + pub kind: JobKind, + pub command: String, + pub description: String, + pub workdir: PathBuf, + pub status: JobStatus, + pub exit_code: Option, + pub started_at: Instant, + pub ended_at: Option, + pub bytes_total: u64, + pub truncated: bool, +} + +#[derive(Debug, Clone)] +pub struct SpawnedJob { + pub task_id: String, + pub pid: Option, +} + +#[derive(Debug, Clone)] +pub struct JobOutput { + pub text: String, + pub status: JobStatus, + pub exit_code: Option, + /// Absolute end offset of the returned slice in the logical output stream. + pub end_offset: u64, + /// Alias for `end_offset` — absolute next read offset for clients. + pub next_offset: u64, + /// Total bytes in the logical stream (log file or ring end offset). + pub bytes_total: u64, + /// Whether the in-memory ring dropped older bytes (ledger logs are not truncated). + pub truncated: bool, +} + +struct RingBuffer { + capacity: usize, + data: Vec, + /// Absolute offset of `data[0]` in the logical stream. + start_offset: u64, + truncated: bool, +} + +impl RingBuffer { + fn new(capacity: usize) -> Self { + Self { + capacity, + data: Vec::new(), + start_offset: 0, + truncated: false, + } + } + + fn end_offset(&self) -> u64 { + self.start_offset + self.data.len() as u64 + } + + fn append(&mut self, chunk: &[u8]) { + if chunk.is_empty() { + return; + } + self.data.extend_from_slice(chunk); + if self.data.len() > self.capacity { + let overflow = self.data.len() - self.capacity; + self.data.drain(..overflow); + self.start_offset += overflow as u64; + self.truncated = true; + } + } + + /// Bytes from absolute `since` offset (clamped to retained window). + fn slice_from(&self, since: u64) -> (Vec, u64) { + let start_idx = since.saturating_sub(self.start_offset) as usize; + let start_idx = start_idx.min(self.data.len()); + (self.data[start_idx..].to_vec(), self.end_offset()) + } +} + +/// In-memory interactive job (dies with the app). Background jobs live in the ledger. +struct ProcessJob { + id: String, + kind: JobKind, + command: String, + description: String, + workdir: PathBuf, + status: JobStatus, + exit_code: Option, + started_at: Instant, + ended_at: Option, + ring: RingBuffer, + /// Last absolute offset returned by `output` for since-last semantics. + last_read_offset: u64, + /// Background: ledger pid/pgid. Interactive: unused. + pid: Option, + process_group_id: Option, + notify: Arc, +} + +impl ProcessJob { + fn snapshot(&self) -> ProcessJobSnapshot { + ProcessJobSnapshot { + id: self.id.clone(), + kind: self.kind, + command: self.command.clone(), + description: self.description.clone(), + workdir: self.workdir.clone(), + status: self.status, + exit_code: self.exit_code, + started_at: self.started_at, + ended_at: self.ended_at, + bytes_total: self.ring.end_offset(), + truncated: self.ring.truncated, + } + } +} + +struct ProcessRegistryInner { + jobs: HashMap, + order: Vec, + /// Legacy counter retained for tests / uniqueness of cache keys if needed. + bg_counter: AtomicU64, + pty_counter: AtomicU64, + notifier: Option, + /// Project workdir used when merging ledger jobs into list(). + workdir: PathBuf, +} + +pub struct ProcessRegistry { + inner: Arc>, + /// Cached running job count for the TUI chip — never block the UI to compute this. + running_count: Arc, +} + +impl Default for ProcessRegistry { + fn default() -> Self { + Self::new() + } +} + +impl ProcessRegistry { + pub fn new() -> Self { + let workdir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + Self::with_workdir(workdir) + } + + pub fn with_workdir(workdir: PathBuf) -> Self { + Self { + inner: Arc::new(Mutex::new(ProcessRegistryInner { + jobs: HashMap::new(), + order: Vec::new(), + bg_counter: AtomicU64::new(0), + pty_counter: AtomicU64::new(0), + notifier: None, + workdir: canonicalize_workdir(&workdir), + })), + running_count: Arc::new(AtomicUsize::new(0)), + } + } + + /// Non-blocking running-job count for the status chip (updated on spawn/kill/list). + pub fn running_count(&self) -> usize { + self.running_count.load(Ordering::Relaxed) + } + + fn recompute_running_count(inner: &ProcessRegistryInner, counter: &AtomicUsize) { + let n = inner + .jobs + .values() + .filter(|j| j.status == JobStatus::Running) + .count(); + counter.store(n, Ordering::Relaxed); + } + + fn lock_inner(&self) -> MutexGuard<'_, ProcessRegistryInner> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Update the project workdir used for ledger filtering (e.g. on workspace change). + pub async fn set_workdir(&self, workdir: impl AsRef) { + let mut inner = self.lock_inner(); + inner.workdir = canonicalize_workdir(workdir.as_ref()); + } + + pub fn set_workdir_blocking(&self, workdir: impl AsRef) { + let path = canonicalize_workdir(workdir.as_ref()); + let mut inner = self.lock_inner(); + inner.workdir = path; + } + + /// Attach a notifier. Prefer calling before any spawn; uses a short lock if the + /// mutex is briefly held. + pub fn with_notifier(self, sender: ChunkSender) -> Self { + { + let mut guard = self.lock_inner(); + guard.notifier = Some(sender); + } + self + } + + pub async fn set_notifier(&self, sender: Option) { + let mut guard = self.lock_inner(); + guard.notifier = sender; + } + + fn next_pty_id(inner: &ProcessRegistryInner) -> String { + let n = inner.pty_counter.fetch_add(1, Ordering::Relaxed) + 1; + format!("pty_{n}") + } + + fn emit(notifier: &Option, job_id: &str, event: BackgroundJobEventKind) { + if let Some(tx) = notifier { + let _ = tx.send(ChunkMessage::BackgroundJobEvent { + job_id: job_id.to_string(), + event, + }); + } + } + + fn prune_finished(inner: &mut ProcessRegistryInner) { + // Only prune in-memory interactive jobs. Background ledger entries stay on disk. + let finished: Vec = inner + .order + .iter() + .filter(|id| { + inner + .jobs + .get(*id) + .map(|j| j.kind == JobKind::Interactive && j.status.is_terminal()) + .unwrap_or(false) + }) + .cloned() + .collect(); + let excess = finished.len().saturating_sub(MAX_FINISHED_JOBS); + for id in finished.into_iter().take(excess) { + inner.jobs.remove(&id); + inner.order.retain(|x| x != &id); + } + } + + fn ledger_to_snapshot(meta: &JobMeta, now: Instant) -> ProcessJobSnapshot { + let started_at = { + let age = (chrono::Utc::now() - meta.started_at) + .to_std() + .unwrap_or(Duration::ZERO); + now.checked_sub(age).unwrap_or(now) + }; + let ended_at = meta.ended_at.map(|ended| { + let age = (chrono::Utc::now() - ended) + .to_std() + .unwrap_or(Duration::ZERO); + now.checked_sub(age).unwrap_or(now) + }); + let log_len = std::fs::metadata(crate::jobs::ledger::log_path(&meta.id)) + .map(|m| m.len()) + .unwrap_or(0); + ProcessJobSnapshot { + id: meta.id.clone(), + kind: JobKind::Background, + command: meta.command.clone(), + description: meta.name.clone(), + workdir: PathBuf::from(&meta.workdir), + status: meta.status.into(), + exit_code: meta.exit_code, + started_at, + ended_at, + bytes_total: log_len, + truncated: false, + } + } + + fn cache_background(inner: &mut ProcessRegistryInner, meta: &JobMeta) { + if inner.jobs.contains_key(&meta.id) { + if let Some(job) = inner.jobs.get_mut(&meta.id) { + job.status = meta.status.into(); + job.exit_code = meta.exit_code; + job.pid = Some(meta.pid); + job.process_group_id = meta.pgid.or(Some(meta.pid)); + if job.status.is_terminal() && job.ended_at.is_none() { + job.ended_at = Some(Instant::now()); + } + } + return; + } + let now = Instant::now(); + let snap_times = Self::ledger_to_snapshot(meta, now); + let job = ProcessJob { + id: meta.id.clone(), + kind: JobKind::Background, + command: meta.command.clone(), + description: meta.name.clone(), + workdir: PathBuf::from(&meta.workdir), + status: meta.status.into(), + exit_code: meta.exit_code, + started_at: snap_times.started_at, + ended_at: snap_times.ended_at, + ring: RingBuffer::new(RING_CAPACITY), + last_read_offset: 0, + pid: Some(meta.pid), + process_group_id: meta.pgid.or(Some(meta.pid)), + notify: Arc::new(Notify::new()), + }; + inner.order.push(meta.id.clone()); + inner.jobs.insert(meta.id.clone(), job); + } + + /// Spawn a detached background job that survives crabcode quit. + /// + /// `cancel` is accepted for API compatibility but does **not** kill the child — + /// background jobs are OS-detached and managed via the on-disk ledger. + pub async fn spawn_background( + &self, + command: impl Into, + description: impl Into, + workdir: impl AsRef, + session_id: Option, + _cancel: CancellationToken, + ) -> Result { + let command = command.into(); + let description = description.into(); + let workdir = workdir.as_ref().to_path_buf(); + + let meta = jobs_spawn::spawn_detached(SpawnDetachedOpts { + command: &command, + name: &description, + workdir: &workdir, + session_id, + }) + .await + .map_err(|e| format!("Failed to spawn background process: {e}"))?; + + let task_id = meta.id.clone(); + let pid = meta.pid; + + { + let mut inner = self.lock_inner(); + // Keep registry workdir in sync with spawn if unset / first use. + if inner.workdir.as_os_str().is_empty() { + inner.workdir = canonicalize_workdir(&workdir); + } + Self::cache_background(&mut inner, &meta); + // bump legacy counter so tests observing it still move + let _ = inner.bg_counter.fetch_add(1, Ordering::Relaxed); + Self::recompute_running_count(&inner, &self.running_count); + Self::emit( + &inner.notifier, + &task_id, + BackgroundJobEventKind::Started { + command: command.clone(), + description: description.clone(), + kind: JobKind::Background.as_str().to_string(), + }, + ); + } + + crate::emit_log!( + "[PROCESS_REGISTRY] spawned detached background task_id={} pid={} cmd={}", + task_id, + pid, + command + ); + + Ok(SpawnedJob { + task_id, + pid: Some(pid), + }) + } + + /// Poll job output. + /// + /// Background jobs read from the on-disk `output.log`. Interactive jobs use the + /// in-memory ring (fed by the PTY / tool). + /// + /// `since_byte`: absolute byte offset. If `None`, returns bytes since the last + /// successful `output` call for this job. + /// + /// `wait_ms`: if set, block until new bytes arrive, the job exits, or timeout. + pub async fn output( + &self, + task_id: &str, + wait_ms: Option, + since_byte: Option, + ) -> Result { + // Prefer ledger path for job_* ids (and any id with a meta.json). + if let Ok(mut meta) = load_meta(task_id) { + let _ = refresh_if_dead(&mut meta); + { + let mut inner = self.lock_inner(); + Self::cache_background(&mut inner, &meta); + } + + let since = { + let inner = self.lock_inner(); + let job = inner.jobs.get(task_id); + since_byte.unwrap_or_else(|| job.map(|j| j.last_read_offset).unwrap_or(0)) + }; + + if let Some(ms) = wait_ms { + let (text, next, _exited) = + jobs_spawn::wait_for_log_growth(task_id, since as usize, ms) + .await + .map_err(|e| e.to_string())?; + let meta = load_meta(task_id).map_err(|e| e.to_string())?; + { + let mut inner = self.lock_inner(); + let notifier = inner.notifier.clone(); + let mut emit_status = None; + if let Some(job) = inner.jobs.get_mut(task_id) { + job.last_read_offset = next as u64; + job.status = meta.status.into(); + job.exit_code = meta.exit_code; + if job.status.is_terminal() && job.ended_at.is_none() { + job.ended_at = Some(Instant::now()); + job.notify.notify_waiters(); + emit_status = Some(job.status); + } + } + Self::recompute_running_count(&inner, &self.running_count); + if let Some(status) = emit_status { + match status { + JobStatus::Killed => { + Self::emit(¬ifier, task_id, BackgroundJobEventKind::Killed) + } + JobStatus::Exited | JobStatus::Failed => Self::emit( + ¬ifier, + task_id, + BackgroundJobEventKind::Exited { + exit_code: meta.exit_code, + }, + ), + JobStatus::Running => {} + } + } + } + return Ok(JobOutput { + text, + status: meta.status.into(), + exit_code: meta.exit_code, + end_offset: next as u64, + next_offset: next as u64, + bytes_total: next as u64, + truncated: false, + }); + } + + let (text, next, _) = + jobs_spawn::read_log_from(task_id, since as usize).map_err(|e| e.to_string())?; + { + let mut inner = self.lock_inner(); + if let Some(job) = inner.jobs.get_mut(task_id) { + job.last_read_offset = next as u64; + job.status = meta.status.into(); + job.exit_code = meta.exit_code; + } + Self::recompute_running_count(&inner, &self.running_count); + } + return Ok(JobOutput { + text, + status: meta.status.into(), + exit_code: meta.exit_code, + end_offset: next as u64, + next_offset: next as u64, + bytes_total: next as u64, + truncated: false, + }); + } + + // Interactive / in-memory path. + let notify = { + let inner = self.lock_inner(); + let job = inner + .jobs + .get(task_id) + .ok_or_else(|| format!("Unknown task_id: {task_id}"))?; + job.notify.clone() + }; + + let deadline = wait_ms.map(|ms| Instant::now() + Duration::from_millis(ms)); + + loop { + { + let mut inner = self.lock_inner(); + let job = inner + .jobs + .get_mut(task_id) + .ok_or_else(|| format!("Unknown task_id: {task_id}"))?; + + let since = since_byte.unwrap_or(job.last_read_offset); + let (bytes, end_offset) = job.ring.slice_from(since); + let has_new = !bytes.is_empty(); + let terminal = job.status.is_terminal(); + + if has_new || terminal || deadline.is_none() { + job.last_read_offset = end_offset; + let text = String::from_utf8_lossy(&bytes).into_owned(); + return Ok(JobOutput { + text, + status: job.status, + exit_code: job.exit_code, + end_offset, + next_offset: end_offset, + bytes_total: job.ring.end_offset(), + truncated: job.ring.truncated, + }); + } + } + + if let Some(deadline) = deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + let mut inner = self.lock_inner(); + let job = inner + .jobs + .get_mut(task_id) + .ok_or_else(|| format!("Unknown task_id: {task_id}"))?; + let since = since_byte.unwrap_or(job.last_read_offset); + let (bytes, end_offset) = job.ring.slice_from(since); + job.last_read_offset = end_offset; + return Ok(JobOutput { + text: String::from_utf8_lossy(&bytes).into_owned(), + status: job.status, + exit_code: job.exit_code, + end_offset, + next_offset: end_offset, + bytes_total: job.ring.end_offset(), + truncated: job.ring.truncated, + }); + } + tokio::select! { + _ = notify.notified() => {} + _ = tokio::time::sleep(remaining.min(OUTPUT_POLL_INTERVAL)) => {} + } + } + } + } + + pub async fn kill(&self, task_id: &str) -> Result<(), String> { + // Ledger-backed background job. + if load_meta(task_id).is_ok() { + let meta = jobs_spawn::kill_job(task_id).map_err(|e| e.to_string())?; + let mut inner = self.lock_inner(); + Self::cache_background(&mut inner, &meta); + if let Some(job) = inner.jobs.get_mut(task_id) { + job.status = JobStatus::Killed; + job.ended_at = Some(Instant::now()); + job.notify.notify_waiters(); + } + Self::recompute_running_count(&inner, &self.running_count); + let notifier = inner.notifier.clone(); + Self::emit(¬ifier, task_id, BackgroundJobEventKind::Killed); + crate::emit_log!("[PROCESS_REGISTRY] killed ledger job task_id={}", task_id); + return Ok(()); + } + + let mut inner = self.lock_inner(); + { + let job = inner + .jobs + .get_mut(task_id) + .ok_or_else(|| format!("Unknown task_id: {task_id}"))?; + + if job.status.is_terminal() { + return Ok(()); + } + + // Interactive: mark killed; actual PTY teardown is handled by the session tool. + if let Some(pgid) = job.process_group_id { + kill_process_group(pgid); + } + job.status = JobStatus::Killed; + job.ended_at = Some(Instant::now()); + job.notify.notify_waiters(); + } + Self::recompute_running_count(&inner, &self.running_count); + let notifier = inner.notifier.clone(); + Self::emit(¬ifier, task_id, BackgroundJobEventKind::Killed); + crate::emit_log!("[PROCESS_REGISTRY] killed task_id={}", task_id); + Ok(()) + } + + /// Restart a ledger-backed background job (same id / command / cwd). + pub async fn restart(&self, task_id: &str) -> Result { + let meta = jobs_spawn::restart_job(task_id).map_err(|e| e.to_string())?; + let mut inner = self.lock_inner(); + Self::cache_background(&mut inner, &meta); + Self::recompute_running_count(&inner, &self.running_count); + let notifier = inner.notifier.clone(); + Self::emit( + ¬ifier, + task_id, + BackgroundJobEventKind::Started { + command: meta.command.clone(), + description: meta.name.clone(), + kind: JobKind::Background.as_str().to_string(), + }, + ); + crate::emit_log!( + "[PROCESS_REGISTRY] restarted ledger job task_id={} pid={}", + task_id, + meta.pid + ); + Ok(meta) + } + + /// Sync restart for TUI / CLI helpers. + pub fn restart_blocking(&self, task_id: &str) -> Result { + self.block_on_async(self.restart(task_id)) + } + + /// Merge interactive (memory) + project ledger jobs into sorted snapshots. + /// + /// Does **not** call `prune_dead()` — age GC is handled by lazy maintenance, + /// and pid liveness is checked lazily in get / output / kill / CLI. + fn build_list_snapshots( + inner: &ProcessRegistryInner, + ledger_metas: &[JobMeta], + now: Instant, + ) -> Vec { + let mut out: Vec = Vec::new(); + + // Interactive (memory-only) first from order, then any ledger snapshots. + for id in inner.order.iter().rev() { + if let Some(job) = inner.jobs.get(id) { + if job.kind == JobKind::Interactive { + out.push(job.snapshot()); + } + } + } + for meta in ledger_metas { + out.push(Self::ledger_to_snapshot(meta, now)); + } + + // Stable-ish: running first, then newest. + out.sort_by(|a, b| { + let ar = a.status == JobStatus::Running; + let br = b.status == JobStatus::Running; + match (ar, br) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => b.started_at.cmp(&a.started_at), + } + }); + out + } + + fn hydrate_ledger_into_cache(inner: &mut ProcessRegistryInner, ledger_metas: &[JobMeta]) { + let mut newly_exited = Vec::new(); + for meta in ledger_metas { + let prev_running = inner + .jobs + .get(&meta.id) + .map(|j| j.status == JobStatus::Running) + .unwrap_or(false); + Self::cache_background(inner, meta); + if prev_running && meta.status != LedgerStatus::Running { + newly_exited.push(( + meta.id.clone(), + JobStatus::from(meta.status), + meta.exit_code, + )); + } + } + let notifier = inner.notifier.clone(); + for (id, status, exit_code) in newly_exited { + match status { + JobStatus::Killed => Self::emit(¬ifier, &id, BackgroundJobEventKind::Killed), + _ => Self::emit(¬ifier, &id, BackgroundJobEventKind::Exited { exit_code }), + } + } + Self::prune_finished(inner); + } + + /// Merge interactive (memory) + project ledger jobs. + pub async fn list(&self) -> Vec { + crate::maintenance::run_lazy_once(); + // Do NOT call prune_dead() here — it walks the FS and probes pids, which + // freezes the TUI when list is reached via block_on from the event loop. + + let workdir = { + let inner = self.lock_inner(); + inner.workdir.clone() + }; + + let ledger_metas = list_for_project(&workdir).unwrap_or_default(); + let now = Instant::now(); + + let mut inner = self.lock_inner(); + Self::hydrate_ledger_into_cache(&mut inner, &ledger_metas); + Self::recompute_running_count(&inner, &self.running_count); + Self::build_list_snapshots(&inner, &ledger_metas, now) + } + + pub async fn get(&self, task_id: &str) -> Option { + if let Ok(mut meta) = load_meta(task_id) { + let _ = refresh_if_dead(&mut meta); + let mut inner = self.lock_inner(); + Self::cache_background(&mut inner, &meta); + Self::recompute_running_count(&inner, &self.running_count); + return Some(Self::ledger_to_snapshot(&meta, Instant::now())); + } + let inner = self.lock_inner(); + inner.jobs.get(task_id).map(|j| j.snapshot()) + } + + fn block_on_async(&self, fut: F) -> T + where + F: std::future::Future, + { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + tokio::task::block_in_place(|| handle.block_on(fut)) + } else { + futures::executor::block_on(fut) + } + } + + /// Sync snapshot list for the TUI (App event loop is sync). + /// + /// Uses a short std Mutex lock + ledger meta reads only — no `block_on`, no `prune_dead`. + pub fn list_blocking(&self) -> Vec { + crate::maintenance::run_lazy_once(); // already spawns a thread — OK + // DO NOT call prune_dead() here + + let workdir = { + let inner = self.lock_inner(); + inner.workdir.clone() + }; + let ledger_metas = list_for_project(&workdir).unwrap_or_default(); + let now = Instant::now(); + + let mut inner = self.lock_inner(); + Self::hydrate_ledger_into_cache(&mut inner, &ledger_metas); + Self::recompute_running_count(&inner, &self.running_count); + Self::build_list_snapshots(&inner, &ledger_metas, now) + } + + /// Sync get for the TUI — load_meta + optional refresh_if_dead, no block_on. + pub fn get_blocking(&self, task_id: &str) -> Option { + if let Ok(mut meta) = load_meta(task_id) { + let _ = refresh_if_dead(&mut meta); + let mut inner = self.lock_inner(); + Self::cache_background(&mut inner, &meta); + Self::recompute_running_count(&inner, &self.running_count); + return Some(Self::ledger_to_snapshot(&meta, Instant::now())); + } + let inner = self.lock_inner(); + inner.jobs.get(task_id).map(|j| j.snapshot()) + } + + /// Deprecated: prefer [`Self::running_count`] (atomic, never lists). + pub fn running_count_blocking(&self) -> usize { + self.running_count() + } + + /// Sync kill for the TUI. + pub fn kill_blocking(&self, task_id: &str) -> Result<(), String> { + self.block_on_async(self.kill(task_id)) + } + + /// Sync output poll for the jobs dialog detail view. + /// + /// Prefer the fast path (no wait) without `block_on`: interactive ring or ledger log. + /// When `wait_ms` is Some, falls back to the async waiter via `block_in_place`. + pub fn output_blocking( + &self, + task_id: &str, + wait_ms: Option, + since_byte: Option, + ) -> Result { + if wait_ms.is_none() { + // Interactive in-memory ring + { + let mut inner = self.lock_inner(); + if let Some(job) = inner.jobs.get_mut(task_id) { + if job.kind == JobKind::Interactive { + let since = since_byte.unwrap_or(job.last_read_offset); + let (bytes, end_offset) = job.ring.slice_from(since); + job.last_read_offset = end_offset; + return Ok(JobOutput { + text: String::from_utf8_lossy(&bytes).into_owned(), + status: job.status, + exit_code: job.exit_code, + end_offset, + next_offset: end_offset, + bytes_total: job.ring.end_offset(), + truncated: job.ring.truncated, + }); + } + } + } + + // Background ledger log + if let Ok(mut meta) = load_meta(task_id) { + let _ = refresh_if_dead(&mut meta); + let since = { + let mut inner = self.lock_inner(); + Self::cache_background(&mut inner, &meta); + Self::recompute_running_count(&inner, &self.running_count); + since_byte.unwrap_or_else(|| { + inner + .jobs + .get(task_id) + .map(|j| j.last_read_offset) + .unwrap_or(0) + }) + }; + let (text, next, _) = jobs_spawn::read_log_from(task_id, since as usize) + .map_err(|e| e.to_string())?; + { + let mut inner = self.lock_inner(); + if let Some(job) = inner.jobs.get_mut(task_id) { + job.last_read_offset = next as u64; + job.status = meta.status.into(); + job.exit_code = meta.exit_code; + } + Self::recompute_running_count(&inner, &self.running_count); + } + return Ok(JobOutput { + text, + status: meta.status.into(), + exit_code: meta.exit_code, + end_offset: next as u64, + next_offset: next as u64, + bytes_total: next as u64, + truncated: false, + }); + } + return Err(format!("Unknown task_id: {task_id}")); + } + + self.block_on_async(self.output(task_id, wait_ms, since_byte)) + } + + /// Register an interactive PTY job so it appears in the jobs list. + pub async fn register_interactive( + &self, + command: impl Into, + description: impl Into, + workdir: impl AsRef, + ) -> String { + let command = command.into(); + let description = description.into(); + let workdir = workdir.as_ref().to_path_buf(); + + let mut inner = self.lock_inner(); + let task_id = Self::next_pty_id(&inner); + let job = ProcessJob { + id: task_id.clone(), + kind: JobKind::Interactive, + command: command.clone(), + description: description.clone(), + workdir, + status: JobStatus::Running, + exit_code: None, + started_at: Instant::now(), + ended_at: None, + ring: RingBuffer::new(RING_CAPACITY), + last_read_offset: 0, + pid: None, + process_group_id: None, + notify: Arc::new(Notify::new()), + }; + Self::emit( + &inner.notifier, + &task_id, + BackgroundJobEventKind::Started { + command, + description, + kind: JobKind::Interactive.as_str().to_string(), + }, + ); + inner.order.push(task_id.clone()); + inner.jobs.insert(task_id.clone(), job); + Self::recompute_running_count(&inner, &self.running_count); + task_id + } + + pub async fn mark_interactive_status( + &self, + id: &str, + status: JobStatus, + exit_code: Option, + ) { + let mut inner = self.lock_inner(); + if let Some(job) = inner.jobs.get_mut(id) { + job.status = status; + job.exit_code = exit_code; + job.ended_at = Some(Instant::now()); + job.notify.notify_waiters(); + let notifier = inner.notifier.clone(); + match status { + JobStatus::Killed => Self::emit(¬ifier, id, BackgroundJobEventKind::Killed), + JobStatus::Exited | JobStatus::Failed => { + Self::emit(¬ifier, id, BackgroundJobEventKind::Exited { exit_code }) + } + JobStatus::Running => {} + } + } + Self::prune_finished(&mut inner); + Self::recompute_running_count(&inner, &self.running_count); + } + + pub async fn unregister(&self, id: &str) { + let mut inner = self.lock_inner(); + inner.jobs.remove(id); + inner.order.retain(|existing| existing != id); + Self::recompute_running_count(&inner, &self.running_count); + } +} + +#[cfg(unix)] +fn kill_process_group(pid: u32) { + if pid > 0 { + unsafe { + let _ = libc::killpg(pid as i32, libc::SIGKILL); + } + } +} + +#[cfg(windows)] +fn kill_process_group(pid: u32) { + if pid == 0 { + return; + } + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jobs::test_env::TempState; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn spawn_echo_background_captures_output() { + let _state = TempState::new(); + let workdir = tempfile::tempdir().unwrap(); + let registry = ProcessRegistry::with_workdir(workdir.path().to_path_buf()); + let spawned = registry + .spawn_background( + "echo hello_bg", + "echo", + workdir.path(), + None, + CancellationToken::new(), + ) + .await + .expect("spawn"); + + assert!(spawned.task_id.starts_with("job_")); + + let mut text = String::new(); + for _ in 0..50 { + let out = registry + .output(&spawned.task_id, Some(100), Some(0)) + .await + .expect("output"); + text = out.text; + if text.contains("hello_bg") || out.status.is_terminal() { + break; + } + } + assert!( + text.contains("hello_bg"), + "expected echo output in log, got: {text:?}" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn kill_background_job() { + let _state = TempState::new(); + let workdir = tempfile::tempdir().unwrap(); + let registry = ProcessRegistry::with_workdir(workdir.path().to_path_buf()); + let spawned = registry + .spawn_background( + "sleep 30", + "sleep", + workdir.path(), + None, + CancellationToken::new(), + ) + .await + .expect("spawn"); + + registry.kill(&spawned.task_id).await.expect("kill"); + let snap = registry.get(&spawned.task_id).await.expect("get"); + assert_eq!(snap.status, JobStatus::Killed); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn list_merges_ledger_for_project() { + let _state = TempState::new(); + let workdir = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let registry = ProcessRegistry::with_workdir(workdir.path().to_path_buf()); + + let a = registry + .spawn_background( + "echo a", + "a", + workdir.path(), + None, + CancellationToken::new(), + ) + .await + .expect("spawn a"); + let b = ProcessRegistry::with_workdir(other.path().to_path_buf()) + .spawn_background("echo b", "b", other.path(), None, CancellationToken::new()) + .await + .expect("spawn b"); + + tokio::time::sleep(Duration::from_millis(100)).await; + let list = registry.list().await; + assert!( + list.iter().any(|j| j.id == a.task_id), + "project job missing: {:?}", + list.iter().map(|j| &j.id).collect::>() + ); + assert!( + list.iter().all(|j| j.id != b.task_id), + "other project job should not appear" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn restart_background_job_reuses_id() { + let _state = TempState::new(); + let workdir = tempfile::tempdir().unwrap(); + let registry = ProcessRegistry::with_workdir(workdir.path().to_path_buf()); + let spawned = registry + .spawn_background( + "sleep 30", + "restart-me", + workdir.path(), + None, + CancellationToken::new(), + ) + .await + .expect("spawn"); + let old_meta = crate::jobs::ledger::load_meta(&spawned.task_id).expect("meta"); + let restarted = registry.restart(&spawned.task_id).await.expect("restart"); + assert_eq!(restarted.id, spawned.task_id); + assert_ne!(restarted.pid, old_meta.pid); + let snap = registry.get(&spawned.task_id).await.expect("get after"); + assert_eq!(snap.status, JobStatus::Running); + let _ = registry.kill(&spawned.task_id).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn interactive_register_and_mark() { + let registry = ProcessRegistry::new(); + let id = registry + .register_interactive("vim", "edit", PathBuf::from("/tmp")) + .await; + assert!(id.starts_with("pty_")); + registry + .mark_interactive_status(&id, JobStatus::Exited, Some(0)) + .await; + let snap = registry.get(&id).await.unwrap(); + assert_eq!(snap.status, JobStatus::Exited); + assert_eq!(snap.kind, JobKind::Interactive); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn running_count_tracks_spawn_and_kill() { + let _state = TempState::new(); + let workdir = tempfile::tempdir().unwrap(); + let registry = ProcessRegistry::with_workdir(workdir.path().to_path_buf()); + assert_eq!(registry.running_count(), 0); + + let spawned = registry + .spawn_background( + "sleep 30", + "count-me", + workdir.path(), + None, + CancellationToken::new(), + ) + .await + .expect("spawn"); + assert_eq!(registry.running_count(), 1); + + registry.kill(&spawned.task_id).await.expect("kill"); + assert_eq!(registry.running_count(), 0); + } + + #[test] + fn list_blocking_works_without_tokio_runtime() { + let _state = TempState::new(); + let workdir = tempfile::tempdir().unwrap(); + let registry = ProcessRegistry::with_workdir(workdir.path().to_path_buf()); + // Must not panic / deadlock when called from a plain sync context. + let list = registry.list_blocking(); + assert!(list.is_empty() || list.iter().all(|j| !j.id.is_empty())); + assert_eq!(registry.running_count_blocking(), registry.running_count()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn running_count_tracks_interactive() { + let registry = ProcessRegistry::new(); + assert_eq!(registry.running_count(), 0); + let id = registry + .register_interactive("vim", "edit", PathBuf::from("/tmp")) + .await; + assert_eq!(registry.running_count(), 1); + registry + .mark_interactive_status(&id, JobStatus::Exited, Some(0)) + .await; + assert_eq!(registry.running_count(), 0); + } +} diff --git a/src/tools/task.rs b/src/tools/task.rs index 014291d7..11ba23b5 100644 --- a/src/tools/task.rs +++ b/src/tools/task.rs @@ -443,6 +443,7 @@ impl ToolHandler for TaskTool { subagent_cancel_token, permissions, max_steps, + ctx.process_registry.clone(), ) .await { diff --git a/src/tools/terminal_session.rs b/src/tools/terminal_session.rs index d6790ae8..8860e214 100644 --- a/src/tools/terminal_session.rs +++ b/src/tools/terminal_session.rs @@ -1,4 +1,5 @@ use crate::llm::{ChunkMessage, ChunkSender}; +use crate::tools::process_registry::{JobStatus, ProcessRegistry}; use crate::tools::{ get_string_param, validate_required, ParameterSchema, ParameterType, Tool, ToolContext, ToolError, ToolHandler, ToolResult, @@ -9,7 +10,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::io::{Read, Write}; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use tokio::sync::{mpsc, oneshot}; @@ -27,6 +29,9 @@ pub struct TerminalSessionStart { pub workdir: Option, pub cols: u16, pub rows: u16, + /// ProcessRegistry id when this session is tracked as an interactive job. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -259,11 +264,15 @@ fn kill_pty_child(child: &mut Box) { pub struct TerminalSessionTool { sender: Option, + registry: Option>, } impl TerminalSessionTool { pub fn new() -> Self { - Self { sender: None } + Self { + sender: None, + registry: None, + } } pub fn with_sender_opt(mut self, sender: Option) -> Self { @@ -271,6 +280,11 @@ impl TerminalSessionTool { self } + pub fn with_registry(mut self, registry: Arc) -> Self { + self.registry = Some(registry); + self + } + async fn run_session( &self, sender: ChunkSender, @@ -527,8 +541,11 @@ impl ToolHandler for TerminalSessionTool { id: "terminal_session".to_string(), description: "Run an interactive shell command in a user-controlled embedded terminal. \ -The user can type input, resize the terminal, and stop the session. Use this for interactive CLIs \ -that need a TTY (prompts, pagers, curses). Prefer non-interactive `bash` when possible." +The user can type input, resize the terminal, and stop the session (ctrl+]); Esc minimizes \ +(parks the session while keeping it running). Use this for interactive CLIs that need a TTY \ +(prompts, pagers, curses). Prefer non-interactive `bash` when possible. For long-running \ +commands you don't need to watch, use `bash` with mode=background and poll with bash_output / \ +stop with bash_kill." .to_string(), parameters: vec![ ParameterSchema { @@ -573,6 +590,29 @@ that need a TTY (prompts, pagers, curses). Prefer non-interactive `bash` when po let session_id = cuid2::create_id(); let tool_call_id = ctx.call_id.clone().unwrap_or_else(|| cuid2::create_id()); + let registry = self + .registry + .clone() + .or_else(|| ctx.process_registry.clone()); + let workdir_path = workdir + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| ctx.workdir().to_path_buf()); + // Prefer a job_id supplied by the caller (e.g. bash interactive already + // registered). Otherwise register here so terminal_session jobs appear. + let supplied_job_id = get_string_param(¶ms, "job_id"); + let job_id = if let Some(id) = supplied_job_id { + Some(id) + } else if let Some(ref registry) = registry { + Some( + registry + .register_interactive(command.clone(), description.clone(), &workdir_path) + .await, + ) + } else { + None + }; + let (control_tx, mut control_rx) = mpsc::unbounded_channel(); let start = TerminalSessionStart { @@ -583,26 +623,59 @@ that need a TTY (prompts, pagers, curses). Prefer non-interactive `bash` when po workdir: workdir.clone(), cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS, + job_id: job_id.clone(), }; - sender - .send(ChunkMessage::TerminalSessionRequest( - TerminalSessionRequest { - start: start.clone(), - control_tx, - }, - )) - .map_err(|_| { - ToolError::Execution("Failed to deliver terminal session request to UI".to_string()) - })?; + if let Err(err) = sender.send(ChunkMessage::TerminalSessionRequest( + TerminalSessionRequest { + start: start.clone(), + control_tx, + }, + )) { + if let (Some(registry), Some(job_id)) = (registry.as_ref(), job_id.as_ref()) { + registry + .mark_interactive_status(job_id, JobStatus::Failed, None) + .await; + } + return Err(ToolError::Execution(format!( + "Failed to deliver terminal session request to UI: {err}" + ))); + } if ctx.is_aborted() { + if let (Some(registry), Some(job_id)) = (registry.as_ref(), job_id.as_ref()) { + registry + .mark_interactive_status(job_id, JobStatus::Killed, None) + .await; + } return Err(ToolError::Execution("Cancelled".to_string())); } - let result = self + let result = match self .run_session(sender.clone(), start, &mut control_rx, ctx) - .await?; + .await + { + Ok(result) => result, + Err(err) => { + if let (Some(registry), Some(job_id)) = (registry.as_ref(), job_id.as_ref()) { + registry + .mark_interactive_status(job_id, JobStatus::Failed, None) + .await; + } + return Err(err); + } + }; + + if let (Some(registry), Some(job_id)) = (registry.as_ref(), job_id.as_ref()) { + let status = if result.stopped_by_user { + JobStatus::Killed + } else { + JobStatus::Exited + }; + registry + .mark_interactive_status(job_id, status, result.exit_code) + .await; + } let output = if result.transcript_plain.trim().is_empty() { "(no output)".to_string() @@ -611,25 +684,27 @@ that need a TTY (prompts, pagers, curses). Prefer non-interactive `bash` when po }; let exit_code = result.exit_code.unwrap_or(-1); - Ok( - ToolResult::new(format!("Terminal session: {}", description), output) - .with_metadata("exit_code", serde_json::json!(exit_code)) - .with_metadata("command", serde_json::json!(command)) - .with_metadata("description", serde_json::json!(description)) - .with_metadata("workdir", serde_json::json!(workdir)) - .with_metadata("session_id", serde_json::json!(result.session_id)) - .with_metadata( - "transcript_bytes", - serde_json::json!(result.transcript_bytes), - ) - .with_metadata( - "transcript_truncated", - serde_json::json!(result.transcript_truncated), - ) - .with_metadata("cols", serde_json::json!(result.cols)) - .with_metadata("rows", serde_json::json!(result.rows)) - .with_metadata("stopped_by_user", serde_json::json!(result.stopped_by_user)), - ) + let mut tool_result = ToolResult::new(format!("Terminal session: {}", description), output) + .with_metadata("exit_code", serde_json::json!(exit_code)) + .with_metadata("command", serde_json::json!(command)) + .with_metadata("description", serde_json::json!(description)) + .with_metadata("workdir", serde_json::json!(workdir)) + .with_metadata("session_id", serde_json::json!(result.session_id)) + .with_metadata( + "transcript_bytes", + serde_json::json!(result.transcript_bytes), + ) + .with_metadata( + "transcript_truncated", + serde_json::json!(result.transcript_truncated), + ) + .with_metadata("cols", serde_json::json!(result.cols)) + .with_metadata("rows", serde_json::json!(result.rows)) + .with_metadata("stopped_by_user", serde_json::json!(result.stopped_by_user)); + if let Some(job_id) = job_id { + tool_result = tool_result.with_metadata("task_id", serde_json::json!(job_id)); + } + Ok(tool_result) } } @@ -691,6 +766,7 @@ mod tests { workdir: None, cols: 80, rows: 24, + job_id: None, }; let ctx = ToolContext::from_cancel_token("session", "message", "Build", CancellationToken::new()); diff --git a/src/ui/components/chat.rs b/src/ui/components/chat.rs index 5ea3e46b..49784036 100644 --- a/src/ui/components/chat.rs +++ b/src/ui/components/chat.rs @@ -6015,8 +6015,57 @@ impl Chat { }) .or_else(|| strip_tool_title(title.as_deref(), "Bash")) .unwrap_or("command"); + let mode = metadata + .as_ref() + .and_then(|m| m.get("mode")) + .and_then(|v| v.as_str()) + .or_else(|| { + args_obj + .and_then(|o| o.get("mode")) + .and_then(|v| v.as_str()) + }) + .unwrap_or("foreground"); + let task_id = metadata + .as_ref() + .and_then(|m| m.get("task_id")) + .and_then(|v| v.as_str()); + let description = metadata + .as_ref() + .and_then(|m| m.get("description")) + .and_then(|v| v.as_str()) + .or_else(|| { + args_obj + .and_then(|o| o.get("description")) + .and_then(|v| v.as_str()) + }); let active = matches!(status.as_str(), "running" | "pending"); - let verb = if active { "Running" } else { "Ran" }; + let verb = if mode == "background" { + if active { + "Background" + } else { + "Background done" + } + } else if mode == "interactive" { + if active { + "Interactive" + } else { + "Interactive done" + } + } else if active { + "Running" + } else { + "Ran" + }; + // Collapsed summary for background jobs: Background · desc · task_id + let bg_summary = if mode == "background" { + let desc = description.unwrap_or(command); + match task_id { + Some(id) => Some(format!("Background · {desc} · {id}")), + None => Some(format!("Background · {desc}")), + } + } else { + None + }; let marker_style = Style::default() .fg(if status == "error" { colors.error @@ -6034,18 +6083,31 @@ impl Chat { }) .add_modifier(Modifier::BOLD); let command_style = Style::default().fg(colors.text); - push_wrapped( - &mut out, - Line::from(vec![ - Span::styled(self.tool_marker(active), marker_style), - Span::raw(" "), - Span::styled(verb.to_string(), title_style), - Span::raw(" "), - Span::styled(command.to_string(), command_style), - ]), - max_width, - Line::from(Span::styled(" ", marker_style)), - ); + if let Some(summary) = bg_summary { + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(summary, title_style), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + } else { + push_wrapped( + &mut out, + Line::from(vec![ + Span::styled(self.tool_marker(active), marker_style), + Span::raw(" "), + Span::styled(verb.to_string(), title_style), + Span::raw(" "), + Span::styled(command.to_string(), command_style), + ]), + max_width, + Line::from(Span::styled(" ", marker_style)), + ); + } if status == "ok" { if let Some(ref preview) = output_preview { let result_style = Style::default() diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index be4173c5..5dcee88b 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -1804,7 +1804,7 @@ impl Dialog { frame.render_widget(footer_paragraph, chunks[5]); } - fn footer_lines(&self, width: u16, colors: ThemeColors) -> Vec> { + pub fn footer_lines(&self, width: u16, colors: ThemeColors) -> Vec> { if self.actions.is_empty() { return vec![Line::from(vec![])]; } diff --git a/src/views/chat.rs b/src/views/chat.rs index 52f10cd3..dcebb3cc 100644 --- a/src/views/chat.rs +++ b/src/views/chat.rs @@ -156,7 +156,10 @@ pub fn render_chat( find_bar: &mut FindBar, show_terminal_cursor: bool, session_title: Option<&str>, + running_jobs: usize, + jobs_chip_area: &mut Option, ) { + *jobs_chip_area = None; let size = f.area(); let is_subagent_view = subagent_tabs .as_ref() @@ -486,10 +489,23 @@ pub fn render_chat( return; } - let help_text = vec![ - Span::styled("ctrl+p", Style::default().fg(colors.info)), - Span::raw(" commands"), - ]; + let mut help_text = Vec::new(); + if running_jobs > 0 { + let chip_label = if running_jobs == 1 { + "● 1 job".to_string() + } else { + format!("● {running_jobs} jobs") + }; + help_text.push(Span::styled( + chip_label, + Style::default() + .fg(colors.warning) + .add_modifier(Modifier::BOLD), + )); + help_text.push(Span::raw(" ")); + } + help_text.push(Span::styled("ctrl+p", Style::default().fg(colors.info))); + help_text.push(Span::raw(" commands")); let help_line = Line::from(help_text); let help_width = help_line.width() as u16; let available_width = above_status_chunks[5].width; @@ -550,8 +566,24 @@ pub fn render_chat( f.render_widget(usage, status_chunks[2]); } - let help = Paragraph::new(help_line).alignment(Alignment::Right); + let help = Paragraph::new(help_line.clone()).alignment(Alignment::Right); f.render_widget(help, status_chunks[3]); + if running_jobs > 0 { + // Right-aligned chip sits at the start of the help line content. + let chip_label_width = if running_jobs == 1 { + "● 1 job".chars().count() as u16 + } else { + format!("● {running_jobs} jobs").chars().count() as u16 + }; + let area = status_chunks[3]; + let chip_x = area.x.saturating_add(area.width.saturating_sub(help_width)); + *jobs_chip_area = Some(Rect { + x: chip_x, + y: area.y, + width: chip_label_width.min(area.width), + height: 1, + }); + } f.render_widget( Block::default().style(Style::default().bg(colors.background)), @@ -2114,6 +2146,8 @@ mod tests { &mut find_bar, true, Some("Session"), + 0, + &mut None, ); }) .expect("draw without sticky"); @@ -2169,6 +2203,8 @@ mod tests { &mut find_bar, true, Some("Session"), + 0, + &mut None, ); }) .expect("draw with sticky"); @@ -2244,6 +2280,8 @@ mod tests { &mut find_bar, true, Some("Session"), + 0, + &mut None, ); }) .expect("draw"); diff --git a/src/views/command_palette.rs b/src/views/command_palette.rs index b827ae8e..741faba2 100644 --- a/src/views/command_palette.rs +++ b/src/views/command_palette.rs @@ -26,6 +26,7 @@ pub enum CommandPaletteAppAction { OpenStorage, OpenSkillsDialog, OpenMcpDialog, + OpenJobs, } #[derive(Debug)] @@ -216,6 +217,7 @@ fn action_for_item(item: &DialogItem) -> CommandPaletteAction { "open-mcp-dialog" => { CommandPaletteAction::RunAppAction(CommandPaletteAppAction::OpenMcpDialog) } + "open-jobs" => CommandPaletteAction::RunAppAction(CommandPaletteAppAction::OpenJobs), _ => CommandPaletteAction::None, }; } @@ -468,6 +470,21 @@ fn core_palette_items( ), ); + items.insert( + items + .iter() + .position(|item| item.group == "Application") + .unwrap_or(items.len()), + app_action_item( + "open-jobs", + "Background Jobs", + "Application", + "List and manage background/interactive shell jobs", + Some("ctrl+x j"), + &["jobs", "background", "bash_output", "bash_kill", "process"], + ), + ); + items.insert( items .iter() diff --git a/src/views/jobs_dialog.rs b/src/views/jobs_dialog.rs new file mode 100644 index 00000000..b0c31bf0 --- /dev/null +++ b/src/views/jobs_dialog.rs @@ -0,0 +1,938 @@ +use crate::theme::ThemeColors; +use crate::tools::process_registry::{JobKind, JobStatus, ProcessJobSnapshot, ProcessRegistry}; +use crate::ui::components::dialog::{ + Dialog, DialogAction as FooterAction, DialogItem, DialogPosition, +}; +use crate::ui::selection::{extract_selected_text, Selection}; +use crate::views::sessions_dialog::session_loading_glyph; +use ratatui::crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, +}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::{layout::Rect, Frame}; +use std::time::Duration; +use unicode_width::UnicodeWidthStr; + +const DETAIL_OUTPUT_CAP: usize = 32_000; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JobsDialogAction { + Close, + Handled, + NotHandled, + /// Restore interactive PTY overlay for this job id + FocusInteractive(String), + /// Kill selected job + Kill(String), + /// Restart selected job (same id / command / cwd) + Restart(String), +} + +#[derive(Debug)] +pub struct JobsDialogState { + pub dialog: Dialog, + /// When Some, showing log detail for this task_id instead of list + detail_task_id: Option, + pub detail_scroll: u16, + detail_text: String, + detail_lines: Vec, + detail_title: String, + detail_command: String, + detail_content_area: Rect, + detail_stick_to_bottom: bool, + pub selection: Selection, +} + +impl JobsDialogState { + pub fn new() -> Self { + let dialog = Dialog::new("Jobs") + .with_position(DialogPosition::Center) + .with_actions(list_actions(None)); + Self { + dialog, + detail_task_id: None, + detail_scroll: 0, + detail_text: String::new(), + detail_lines: Vec::new(), + detail_title: String::new(), + detail_command: String::new(), + detail_content_area: Rect::default(), + detail_stick_to_bottom: true, + selection: Selection::new(), + } + } + + pub fn show(&mut self) { + self.detail_task_id = None; + self.detail_scroll = 0; + self.detail_text.clear(); + self.detail_lines.clear(); + self.detail_command.clear(); + self.detail_stick_to_bottom = true; + self.selection.clear(); + self.dialog.show(); + } + + pub fn hide(&mut self) { + self.detail_task_id = None; + self.detail_scroll = 0; + self.detail_text.clear(); + self.detail_lines.clear(); + self.detail_command.clear(); + self.selection.clear(); + self.dialog.hide(); + } + + pub fn is_visible(&self) -> bool { + self.dialog.is_visible() + } + + pub fn is_detail_open(&self) -> bool { + self.detail_task_id.is_some() + } + + pub fn detail_task_id(&self) -> Option<&str> { + self.detail_task_id.as_deref() + } + + pub fn detail_content_area(&self) -> Rect { + self.detail_content_area + } + + pub fn selected_text(&self) -> Option { + if !self.selection.active { + return None; + } + let lines: Vec> = self + .detail_lines + .iter() + .map(|s| Line::from(s.as_str())) + .collect(); + extract_selected_text(&lines, &self.selection).filter(|t| !t.is_empty()) + } + + pub fn clear_selection(&mut self) { + self.selection.clear(); + } + + pub fn refresh_from_registry(&mut self, registry: &ProcessRegistry, spinner_frame: usize) { + let snaps = registry.list_blocking(); + self.refresh_from_snapshots(&snaps, spinner_frame); + } + + pub fn refresh_from_snapshots(&mut self, snaps: &[ProcessJobSnapshot], spinner_frame: usize) { + let selected_id = self + .dialog + .get_selected() + .map(|item| item.id.clone()) + .or_else(|| self.detail_task_id.clone()); + + let mut items = Vec::with_capacity(snaps.len()); + for snap in snaps { + let name = derive_job_name(&snap.description, &snap.command); + let icon = job_status_icon(snap, spinner_frame); + let elapsed = job_elapsed(snap); + items.push(DialogItem { + id: snap.id.clone(), + name: format!("{icon} {name}"), + description: String::new(), + group: String::new(), + tip: Some(format_job_duration(elapsed)), + provider_id: name, + active: false, + }); + } + + let selected = match selected_id.as_deref() { + Some(id) => snaps.iter().find(|s| s.id == id), + None => None, + }; + self.dialog.set_items(items); + self.dialog.actions = list_actions(selected); + + if let Some(id) = selected_id { + let _ = self.dialog.select_item_by_id(&id); + } + } + + /// Re-fetch running job output while detail is open. Preserves scroll unless + /// the view was stuck to the bottom. + pub fn refresh_detail_output(&mut self, registry: &ProcessRegistry) -> bool { + let Some(task_id) = self.detail_task_id.clone() else { + return false; + }; + let Some(snap) = registry.get_blocking(&task_id) else { + return false; + }; + if !matches!(snap.status, JobStatus::Running) { + // Still refresh once so tip/status icons stay accurate if job just ended. + self.open_detail_from_snapshot(&snap, registry); + return false; + } + self.open_detail_from_snapshot(&snap, registry); + true + } + + fn open_detail(&mut self, registry: &ProcessRegistry) { + let Some(item) = self.dialog.get_selected() else { + return; + }; + let id = item.id.clone(); + let Some(snap) = registry.get_blocking(&id) else { + return; + }; + self.open_detail_from_snapshot(&snap, registry); + } + + fn open_detail_from_snapshot(&mut self, snap: &ProcessJobSnapshot, registry: &ProcessRegistry) { + let was_stuck = + self.detail_stick_to_bottom || self.detail_task_id.as_deref() != Some(snap.id.as_str()); + let prev_scroll = self.detail_scroll; + + // Never wait on the UI thread — a wait here freezes the whole app + // (including Esc-to-close) until the timeout fires. + let output = registry + .output_blocking(&snap.id, None, Some(0)) + .map(|o| o.text) + .unwrap_or_default(); + let text = truncate_detail(&output); + let lines: Vec = text.lines().map(str::to_string).collect(); + + self.detail_task_id = Some(snap.id.clone()); + self.detail_title = derive_job_name(&snap.description, &snap.command); + self.detail_command = snap.command.clone(); + self.detail_text = text; + self.detail_lines = lines; + if was_stuck { + self.detail_scroll = u16::MAX; + self.detail_stick_to_bottom = true; + } else { + self.detail_scroll = prev_scroll; + } + // Selection stays unless content shrank past it — keep simple and clear. + if self.selection.active { + let max_line = self.detail_lines.len().saturating_sub(1); + if self.selection.start_line > max_line || self.selection.end_line > max_line { + self.selection.clear(); + } + } + self.dialog.actions = detail_actions(snap); + } + + fn close_detail(&mut self, registry: &ProcessRegistry, spinner_frame: usize) { + self.detail_task_id = None; + self.detail_scroll = 0; + self.detail_text.clear(); + self.detail_lines.clear(); + self.detail_command.clear(); + self.detail_stick_to_bottom = true; + self.selection.clear(); + self.refresh_from_registry(registry, spinner_frame); + } + + fn selected_snapshot<'a>( + &self, + snaps: &'a [ProcessJobSnapshot], + ) -> Option<&'a ProcessJobSnapshot> { + let id = self.dialog.get_selected()?.id.clone(); + snaps.iter().find(|s| s.id == id) + } +} + +impl Default for JobsDialogState { + fn default() -> Self { + Self::new() + } +} + +/// Grok-style duration buckets: <10s → "1.2s"; <60 → "12s"; <60m → "1m5s"; else "1h2m". +pub fn format_job_duration(d: Duration) -> String { + let total_secs = d.as_secs_f64(); + if total_secs < 10.0 { + format!("{:.1}s", total_secs) + } else if total_secs < 60.0 { + format!("{}s", total_secs as u64) + } else if total_secs < 3600.0 { + let mins = (total_secs / 60.0) as u64; + let secs = (total_secs % 60.0) as u64; + format!("{mins}m{secs}s") + } else { + let hours = (total_secs / 3600.0) as u64; + let mins = ((total_secs % 3600.0) / 60.0) as u64; + format!("{hours}h{mins}m") + } +} + +/// Prefer agent description (short), else derive a title-cased name from the command. +pub fn derive_job_name(description: &str, command: &str) -> String { + let d = description.trim(); + if !d.is_empty() { + return truncate_words(d, 6); + } + derive_name_from_command(command) +} + +fn derive_name_from_command(command: &str) -> String { + let stripped = strip_env_assignments(command.trim()); + if stripped.is_empty() { + return "Job".to_string(); + } + + let tokens: Vec<&str> = stripped.split_whitespace().collect(); + if tokens.is_empty() { + return "Job".to_string(); + } + + let mut words: Vec = Vec::new(); + let bin = file_stem(tokens[0]); + words.push(titlecase_ascii(&bin)); + + let mut i = 1usize; + // Skip common package-manager glue: run / exec / x / npx wrappers already handled by bin. + while i < tokens.len() && words.len() < 4 { + let t = tokens[i]; + i += 1; + if t.starts_with('-') { + // Keep meaningful short flags only when alone would look empty. + continue; + } + if matches!( + t, + "run" | "exec" | "cmd" | "command" | "--" | "yarn" | "pnpm" | "npx" | "bunx" + ) { + continue; + } + // Skip path-like / file args after we already have a couple words. + if words.len() >= 2 && (t.contains('/') || t.contains('.')) { + continue; + } + words.push(titlecase_ascii(t)); + } + + if words.is_empty() { + "Job".to_string() + } else { + words.join(" ") + } +} + +fn strip_env_assignments(command: &str) -> String { + let mut out = Vec::new(); + let mut skipping_env = true; + for token in command.split_whitespace() { + if skipping_env { + if let Some((k, _)) = token.split_once('=') { + if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + continue; + } + } + skipping_env = false; + } + out.push(token); + } + out.join(" ") +} + +fn file_stem(path: &str) -> String { + let name = path.rsplit('/').next().unwrap_or(path); + let name = name.rsplit('\\').next().unwrap_or(name); + name.to_string() +} + +fn titlecase_ascii(value: &str) -> String { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + first.to_ascii_uppercase().to_string() + chars.as_str() +} + +fn truncate_words(s: &str, max_words: usize) -> String { + let words: Vec<&str> = s.split_whitespace().collect(); + if words.len() <= max_words { + words.join(" ") + } else { + format!("{}…", words[..max_words].join(" ")) + } +} + +fn job_elapsed(snap: &ProcessJobSnapshot) -> Duration { + match snap.ended_at { + Some(ended) => ended.saturating_duration_since(snap.started_at), + None => snap.started_at.elapsed(), + } +} + +fn job_status_icon(snap: &ProcessJobSnapshot, spinner_frame: usize) -> String { + match snap.status { + JobStatus::Running => session_loading_glyph(spinner_frame).to_string(), + JobStatus::Exited if snap.exit_code.unwrap_or(0) == 0 => "✓".to_string(), + JobStatus::Exited | JobStatus::Failed | JobStatus::Killed => "✗".to_string(), + } +} + +/// Stable footer — always the same actions so height doesn't jump on selection. +fn list_actions(_selected: Option<&ProcessJobSnapshot>) -> Vec { + vec![ + FooterAction { + key: "esc".into(), + label: "close".into(), + }, + FooterAction { + key: "enter".into(), + label: "view".into(), + }, + FooterAction { + key: "x".into(), + label: "kill".into(), + }, + FooterAction { + key: "r".into(), + label: "restart".into(), + }, + ] +} + +/// Stable footer for detail view (same count every time). +fn detail_actions(_snap: &ProcessJobSnapshot) -> Vec { + vec![ + FooterAction { + key: "esc".into(), + label: "back".into(), + }, + FooterAction { + key: "↑↓".into(), + label: "scroll".into(), + }, + FooterAction { + key: "x".into(), + label: "kill".into(), + }, + FooterAction { + key: "r".into(), + label: "restart".into(), + }, + FooterAction { + key: "y".into(), + label: "copy sel".into(), + }, + ] +} + +fn truncate_detail(text: &str) -> String { + if text.len() <= DETAIL_OUTPUT_CAP { + text.to_string() + } else { + let start = text.len() - DETAIL_OUTPUT_CAP; + let start = text + .char_indices() + .find(|(i, _)| *i >= start) + .map(|(i, _)| i) + .unwrap_or(0); + format!("…[truncated]\n{}", &text[start..]) + } +} + +pub fn init_jobs_dialog() -> JobsDialogState { + JobsDialogState::new() +} + +pub fn render_jobs_dialog( + f: &mut Frame, + state: &mut JobsDialogState, + area: Rect, + colors: ThemeColors, +) { + if state.detail_task_id.is_some() { + render_detail(f, state, area, colors); + } else { + state.dialog.render(f, area, colors); + } +} + +fn render_detail(f: &mut Frame, state: &mut JobsDialogState, area: Rect, colors: ThemeColors) { + let width = ((area.width as f32) * 0.9).round() as u16; + let width = width.max(40).min(area.width.saturating_sub(2)); + let height = ((area.height as f32) * 0.85).round() as u16; + let height = height.max(12).min(area.height.saturating_sub(1)); + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let panel = Rect { + x, + y, + width, + height, + }; + // Keep for mouse hit-testing / selection bar placement. + state.dialog.dialog_area = panel; + + f.render_widget(Clear, panel); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", state.detail_title)) + .border_style(Style::default().fg(colors.border)) + .style(Style::default().bg(colors.background)); + let inner = block.inner(panel); + f.render_widget(block, panel); + + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + ratatui::layout::Constraint::Length(1), // $ command + ratatui::layout::Constraint::Length(1), // blank + ratatui::layout::Constraint::Min(3), // output + ratatui::layout::Constraint::Length(1), // footer + ]) + .split(inner); + + let cmd_line = Line::from(Span::styled( + format!("$ {}", state.detail_command), + Style::default().fg(colors.text_weak), + )); + f.render_widget(Paragraph::new(cmd_line), chunks[0]); + + let content_area = chunks[2]; + state.detail_content_area = content_area; + + let max_scroll = state + .detail_lines + .len() + .saturating_sub(content_area.height as usize); + if state.detail_stick_to_bottom || state.detail_scroll == u16::MAX { + state.detail_scroll = max_scroll as u16; + state.detail_stick_to_bottom = true; + } else if state.detail_scroll as usize > max_scroll { + state.detail_scroll = max_scroll as u16; + } + + let start = state.detail_scroll as usize; + let end = (start + content_area.height as usize).min(state.detail_lines.len()); + let mut lines: Vec = Vec::with_capacity(end.saturating_sub(start)); + for line_text in &state.detail_lines[start..end] { + lines.push(Line::from(Span::styled( + line_text.clone(), + Style::default().fg(colors.text), + ))); + } + let lines = crate::ui::selection::apply_selection_to_lines_with_offset( + lines, + &state.selection, + colors.accent, + start, + ); + + f.render_widget( + Paragraph::new(lines) + .style(Style::default().bg(colors.background)) + .wrap(Wrap { trim: false }), + content_area, + ); + + // Match Dialog/models footer: label primary+bold, key text_weak+dim + let footer_lines = state.dialog.footer_lines(chunks[3].width, colors); + f.render_widget(Paragraph::new(footer_lines), chunks[3]); +} + +fn detail_mouse_to_pos( + mouse: MouseEvent, + content_area: Rect, + scroll: u16, + lines: &[String], +) -> Option<(usize, usize)> { + if mouse.column < content_area.x + || mouse.row < content_area.y + || mouse.column >= content_area.x.saturating_add(content_area.width) + || mouse.row >= content_area.y.saturating_add(content_area.height) + { + return None; + } + let rel_row = (mouse.row - content_area.y) as usize; + let rel_col = (mouse.column - content_area.x) as usize; + let line_idx = scroll as usize + rel_row; + if line_idx >= lines.len() { + // Allow selecting past last line as end-of-last-line. + let last = lines.len().saturating_sub(1); + let col = lines.get(last).map(|l| l.width()).unwrap_or(0); + return Some((last, col)); + } + let line_width = lines[line_idx].width(); + Some((line_idx, rel_col.min(line_width))) +} + +pub fn handle_jobs_dialog_key_event( + state: &mut JobsDialogState, + key: KeyEvent, + registry: &ProcessRegistry, + spinner_frame: usize, +) -> JobsDialogAction { + if !state.is_visible() { + return JobsDialogAction::NotHandled; + } + + if state.detail_task_id.is_some() { + return handle_detail_key(state, key, registry, spinner_frame); + } + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => JobsDialogAction::Close, + (KeyCode::Enter, _) => { + state.open_detail(registry); + JobsDialogAction::Handled + } + (KeyCode::Char('f'), _) => { + if let Some(item) = state.dialog.get_selected() { + let id = item.id.clone(); + if let Some(snap) = registry.get_blocking(&id) { + if matches!(snap.kind, JobKind::Interactive) { + return JobsDialogAction::FocusInteractive(id); + } + } + } + JobsDialogAction::Handled + } + (KeyCode::Char('x'), _) => { + if let Some(item) = state.dialog.get_selected() { + let id = item.id.clone(); + if let Some(snap) = registry.get_blocking(&id) { + if matches!(snap.status, JobStatus::Running) { + return JobsDialogAction::Kill(id); + } + } + } + JobsDialogAction::Handled + } + (KeyCode::Char('r'), KeyModifiers::CONTROL) => { + state.refresh_from_registry(registry, spinner_frame); + JobsDialogAction::Handled + } + (KeyCode::Char('r'), _) => { + if let Some(item) = state.dialog.get_selected() { + return JobsDialogAction::Restart(item.id.clone()); + } + JobsDialogAction::Handled + } + _ => { + // Let Dialog handle navigation / search. + if state.dialog.handle_key_event(key) { + // Refresh footer actions for newly selected row. + let snaps = registry.list_blocking(); + let selected = state.selected_snapshot(&snaps); + state.dialog.actions = list_actions(selected); + JobsDialogAction::Handled + } else { + JobsDialogAction::NotHandled + } + } + } +} + +fn handle_detail_key( + state: &mut JobsDialogState, + key: KeyEvent, + registry: &ProcessRegistry, + spinner_frame: usize, +) -> JobsDialogAction { + match (key.code, key.modifiers) { + (KeyCode::Esc, _) => { + state.close_detail(registry, spinner_frame); + JobsDialogAction::Handled + } + (KeyCode::Char('f'), _) => { + if let Some(id) = state.detail_task_id.clone() { + if let Some(snap) = registry.get_blocking(&id) { + if matches!(snap.kind, JobKind::Interactive) { + return JobsDialogAction::FocusInteractive(id); + } + } + } + JobsDialogAction::Handled + } + (KeyCode::Char('x'), _) => { + if let Some(id) = state.detail_task_id.clone() { + if let Some(snap) = registry.get_blocking(&id) { + if matches!(snap.status, JobStatus::Running) { + return JobsDialogAction::Kill(id); + } + } + } + JobsDialogAction::Handled + } + (KeyCode::Char('r'), _) => { + if let Some(id) = state.detail_task_id.clone() { + return JobsDialogAction::Restart(id); + } + JobsDialogAction::Handled + } + (KeyCode::Char('y'), _) => { + // App layer also handles yank via SelectionActionTarget; treat as handled + // so Dialog doesn't eat it. Actual copy is done by App when selection exists. + JobsDialogAction::Handled + } + (KeyCode::Up | KeyCode::Char('k'), _) => { + state.detail_scroll = state.detail_scroll.saturating_sub(1); + state.detail_stick_to_bottom = false; + JobsDialogAction::Handled + } + (KeyCode::Down | KeyCode::Char('j'), _) => { + state.detail_scroll = state.detail_scroll.saturating_add(1); + state.detail_stick_to_bottom = false; + JobsDialogAction::Handled + } + (KeyCode::PageUp, _) => { + let page = state.detail_content_area.height.max(1); + state.detail_scroll = state.detail_scroll.saturating_sub(page); + state.detail_stick_to_bottom = false; + JobsDialogAction::Handled + } + (KeyCode::PageDown, _) => { + let page = state.detail_content_area.height.max(1); + state.detail_scroll = state.detail_scroll.saturating_add(page); + state.detail_stick_to_bottom = false; + JobsDialogAction::Handled + } + (KeyCode::Home, _) => { + state.detail_scroll = 0; + state.detail_stick_to_bottom = false; + JobsDialogAction::Handled + } + (KeyCode::End, _) => { + state.detail_scroll = u16::MAX; + state.detail_stick_to_bottom = true; + JobsDialogAction::Handled + } + _ => JobsDialogAction::NotHandled, + } +} + +pub fn handle_jobs_dialog_mouse_event( + state: &mut JobsDialogState, + mouse: MouseEvent, + registry: &ProcessRegistry, + spinner_frame: usize, +) -> JobsDialogAction { + if !state.is_visible() { + return JobsDialogAction::NotHandled; + } + + if state.detail_task_id.is_some() { + return handle_detail_mouse(state, mouse, registry, spinner_frame); + } + + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) + | MouseEventKind::ScrollDown + | MouseEventKind::ScrollUp => { + if state.dialog.handle_mouse_event(mouse) { + let snaps = registry.list_blocking(); + let selected = state.selected_snapshot(&snaps); + state.dialog.actions = list_actions(selected); + // Double-click / activate via dialog enter path is key-only; + // single click just selects. + JobsDialogAction::Handled + } else { + JobsDialogAction::NotHandled + } + } + _ => JobsDialogAction::NotHandled, + } +} + +fn handle_detail_mouse( + state: &mut JobsDialogState, + mouse: MouseEvent, + registry: &ProcessRegistry, + spinner_frame: usize, +) -> JobsDialogAction { + let content = state.detail_content_area; + match mouse.kind { + MouseEventKind::ScrollUp => { + state.detail_scroll = state.detail_scroll.saturating_sub(3); + state.detail_stick_to_bottom = false; + JobsDialogAction::Handled + } + MouseEventKind::ScrollDown => { + state.detail_scroll = state.detail_scroll.saturating_add(3); + state.detail_stick_to_bottom = false; + JobsDialogAction::Handled + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some((line, col)) = + detail_mouse_to_pos(mouse, content, state.detail_scroll, &state.detail_lines) + { + state.selection.start(line, col); + JobsDialogAction::Handled + } else { + // Click outside content clears selection / ignores. + if state.selection.active { + state.selection.clear(); + JobsDialogAction::Handled + } else { + JobsDialogAction::NotHandled + } + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if state.selection.is_dragging { + if let Some((line, col)) = + detail_mouse_to_pos(mouse, content, state.detail_scroll, &state.detail_lines) + { + state.selection.extend(line, col); + } + JobsDialogAction::Handled + } else { + JobsDialogAction::NotHandled + } + } + MouseEventKind::Up(MouseButton::Left) => { + if state.selection.is_dragging { + state.selection.finish(); + // App shows the floating action bar when selection is non-empty. + JobsDialogAction::Handled + } else { + JobsDialogAction::NotHandled + } + } + _ => { + let _ = (registry, spinner_frame); + JobsDialogAction::NotHandled + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tokio_util::sync::CancellationToken; + + #[test] + fn format_job_duration_buckets() { + assert_eq!(format_job_duration(Duration::from_millis(1200)), "1.2s"); + assert_eq!(format_job_duration(Duration::from_secs(12)), "12s"); + assert_eq!(format_job_duration(Duration::from_secs(65)), "1m5s"); + assert_eq!(format_job_duration(Duration::from_secs(3725)), "1h2m"); + } + + #[test] + fn derive_job_name_prefers_description() { + assert_eq!( + derive_job_name("Dev Server Hot Reload", "bun run dev --port 3000"), + "Dev Server Hot Reload" + ); + assert_eq!( + derive_job_name("one two three four five six seven", "ignored"), + "one two three four five six…" + ); + } + + #[test] + fn derive_job_name_from_command() { + assert_eq!(derive_job_name("", "bun run dev"), "Bun Dev"); + assert_eq!(derive_job_name("", "npm run build --watch"), "Npm Build"); + assert_eq!(derive_job_name("", "cargo test"), "Cargo Test"); + assert_eq!( + derive_job_name("", "FOO=1 BAR=2 bun run start"), + "Bun Start" + ); + } + + #[test] + fn init_uses_center_position() { + let state = init_jobs_dialog(); + assert!(matches!(state.dialog.position, DialogPosition::Center)); + assert_eq!(state.dialog.title, "Jobs"); + } + + #[test] + fn footer_actions_include_restart() { + assert!(list_actions(None) + .iter() + .any(|a| a.key == "r" && a.label == "restart")); + let snap = ProcessJobSnapshot { + id: "job_test".into(), + kind: JobKind::Background, + command: "sleep 1".into(), + description: "n".into(), + workdir: std::path::PathBuf::from("/tmp"), + status: JobStatus::Running, + exit_code: None, + started_at: std::time::Instant::now(), + ended_at: None, + bytes_total: 0, + truncated: false, + }; + assert!(detail_actions(&snap) + .iter() + .any(|a| a.key == "r" && a.label == "restart")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn refresh_builds_flat_items_with_duration_tip() { + let _state = crate::jobs::test_env::TempState::new(); + let workdir = tempfile::tempdir().expect("workdir"); + let registry = ProcessRegistry::with_workdir(workdir.path().to_path_buf()); + let spawned = registry + .spawn_background( + "echo jobs_dialog_test", + "echo test", + workdir.path(), + None, + CancellationToken::new(), + ) + .await + .expect("spawn"); + + for _ in 0..40 { + let out = registry + .output(&spawned.task_id, Some(50), Some(0)) + .await + .expect("output"); + if out.status.is_terminal() { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + + let mut state = init_jobs_dialog(); + let snaps = registry.list().await; + state.refresh_from_snapshots(&snaps, 0); + let item = state + .dialog + .items + .iter() + .find(|item| item.id == spawned.task_id) + .expect("expected spawned job in dialog items"); + assert!( + item.group.is_empty(), + "jobs list must be flat (empty group)" + ); + assert!( + item.active == false, + "active must be false to avoid ● prefix" + ); + let tip = item.tip.as_deref().unwrap_or(""); + assert!( + tip.ends_with('s') || tip.contains('m') || tip.contains('h'), + "tip should be duration-only, got {tip:?}" + ); + assert!( + !tip.to_lowercase().contains("running") && !tip.to_lowercase().contains("exited"), + "tip must omit status words: {tip:?}" + ); + assert!( + item.name.contains('✓') + || item.name.contains('✗') + || item.name.contains('⠋') + || item.name.contains('·') + || item + .name + .chars() + .next() + .is_some_and(|c| !c.is_ascii_alphanumeric()), + "name should start with status icon, got {:?}", + item.name + ); + } +} diff --git a/src/views/mod.rs b/src/views/mod.rs index cbd5ae7e..badc7ee4 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -3,6 +3,7 @@ pub mod chat; pub mod command_palette; pub mod connect_dialog; pub mod home; +pub mod jobs_dialog; pub mod mcp_dialog; pub mod models_dialog; pub mod move_session_dialog; @@ -25,6 +26,7 @@ pub use agents_dialog::AgentsDialogState; pub use chat::ChatState; pub use connect_dialog::ConnectDialogState; pub use home::HomeState; +pub use jobs_dialog::JobsDialogState; pub use mcp_dialog::McpDialogState; pub use models_dialog::ModelsDialogState; pub use move_session_dialog::MoveSessionDialogState; diff --git a/src/views/terminal_session_dialog.rs b/src/views/terminal_session_dialog.rs index c77ad7fa..ad07e95f 100644 --- a/src/views/terminal_session_dialog.rs +++ b/src/views/terminal_session_dialog.rs @@ -25,6 +25,8 @@ const PADDING_Y: u16 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TerminalSessionResponse { Close, + /// Park the session (keep running) and dismiss the overlay. + Minimize, Handled, NotHandled, } @@ -68,6 +70,13 @@ impl TerminalSessionDialogState { self.current.is_some() } + /// ProcessRegistry job id for the active interactive session, if tracked. + pub fn active_job_id(&self) -> Option<&str> { + self.current + .as_ref() + .and_then(|a| a.start.job_id.as_deref()) + } + pub fn is_user_controlled(&self) -> bool { self.user_controlled } @@ -289,6 +298,13 @@ pub fn handle_terminal_session_dialog_key_event( }; } + // Esc alone minimizes (park session, keep running). Ctrl+] kills/stops. + // Programs that need Esc can still receive it via Ctrl+[ on terminals that + // distinguish the chord; many map Ctrl+[ to KeyCode::Esc as well. + if event.code == KeyCode::Esc && event.modifiers.is_empty() { + return TerminalSessionResponse::Minimize; + } + if let Some(bytes) = encode_terminal_key(event) { state.send_input(bytes); return TerminalSessionResponse::Handled; @@ -398,13 +414,20 @@ pub fn render_terminal_session_dialog( } let footer = Line::from(vec![ + Span::styled( + "esc", + Style::default() + .fg(colors.primary) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" minimize ", Style::default().fg(colors.text_weak)), Span::styled( "ctrl+]", Style::default() .fg(colors.primary) .add_modifier(Modifier::BOLD), ), - Span::styled(" close/stop", Style::default().fg(colors.text_weak)), + Span::styled(" stop", Style::default().fg(colors.text_weak)), ]); f.render_widget(Paragraph::new(footer), chunks[4]); } @@ -529,6 +552,7 @@ mod tests { workdir: None, cols: 80, rows: 24, + job_id: None, } } @@ -628,6 +652,21 @@ mod tests { assert!(!state.has_active()); } + #[test] + fn esc_minimizes_without_stopping() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut state = TerminalSessionDialogState::new(); + state.enqueue(TerminalSessionRequest { + start: sample_start("1", "t"), + control_tx: tx, + }); + let resp = + handle_terminal_session_dialog_key_event(&mut state, KeyEvent::from(KeyCode::Esc)); + assert_eq!(resp, TerminalSessionResponse::Minimize); + assert!(state.has_active()); + assert!(rx.try_recv().is_err(), "Esc must not send Stop to PTY"); + } + #[test] fn ctrl_five_closes_dialog_for_legacy_terminal_encoding() { let (tx, mut rx) = mpsc::unbounded_channel(); diff --git a/src/views/which_key.rs b/src/views/which_key.rs index f38501c0..7fbd0f34 100644 --- a/src/views/which_key.rs +++ b/src/views/which_key.rs @@ -18,6 +18,8 @@ pub enum WhichKeyAction { ShowThemes, ShowSessions, ShowTimeline, + /// Ctrl+X opens WhichKey; bind `j` here for jobs (don't steal Ctrl+X). + ShowJobs, ToggleThinking, GoChild, GoParent, @@ -84,6 +86,12 @@ impl WhichKeyState { description: "Create new session".to_string(), target: BindingTarget::Action(WhichKeyAction::NewSession), }, + // Ctrl+X opens WhichKey; jobs are bound here (don't steal Ctrl+X). + KeyBinding { + key: "j".to_string(), + description: "Jobs (background/interactive)".to_string(), + target: BindingTarget::Action(WhichKeyAction::ShowJobs), + }, KeyBinding { key: "q".to_string(), description: "Quit application".to_string(), From d4b7c55d5f5f9993c08aea0b3d35ac1873366bd0 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 28 Aug 2026 07:18:19 +0800 Subject: [PATCH 2/3] fix: correct hollow-args detection for non-search tools and misc cleanup - Fix `hosted_search_args_are_hollow` to exclude non-search tool args (read/list/grep/glob) from being treated as hollow, which broke exploration grouping - Simplify selection action bar dismissal logic in jobs dialog - Replace manual `Default` impl for `RunOpts` with `#[derive(Default)]` - Use `sort_by_key` with `Reverse` in ledger sort - Replace `load_from_file("src/theme.json")` with `load_builtin_default()` in question dialog tests - Add `bash_restart` permission assertion for plan mode - Update command count and help text assertions --- src/app.rs | 16 +++++++--------- src/command/handlers.rs | 2 +- src/jobs/ledger.rs | 4 ++-- src/llm/client.rs | 15 +++++++++++++++ src/main.rs | 6 ++++-- src/maintenance/mod.rs | 11 +---------- src/tools/permission.rs | 1 + src/views/question_dialog.rs | 36 +++++++++--------------------------- 8 files changed, 40 insertions(+), 51 deletions(-) diff --git a/src/app.rs b/src/app.rs index 82ced582..fd7b307a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5075,16 +5075,14 @@ impl App { self.show_selection_action_bar_for(SelectionActionTarget::JobsDetail); } MouseEventKind::Down(MouseButton::Left) - if !self.jobs_dialog_state.selection.active => + if !self.jobs_dialog_state.selection.active + && self.selection_action_bar + == Some(SelectionActionBarState { + target: SelectionActionTarget::JobsDetail, + can_open_in_editor: false, + }) => { - if self.selection_action_bar - == Some(SelectionActionBarState { - target: SelectionActionTarget::JobsDetail, - can_open_in_editor: false, - }) - { - self.selection_action_bar = None; - } + self.selection_action_bar = None; } _ => {} } diff --git a/src/command/handlers.rs b/src/command/handlers.rs index 95f4e1ab..45957c06 100644 --- a/src/command/handlers.rs +++ b/src/command/handlers.rs @@ -1389,7 +1389,7 @@ mod tests { async fn test_registry_has_all_commands() { let registry = create_registry(); let names = registry.get_command_names(); - assert_eq!(names.len(), 19); + assert_eq!(names.len(), 20); assert!(names.contains(&"exit".to_string())); assert!(names.contains(&"sessions".to_string())); assert!(names.contains(&"new".to_string())); diff --git a/src/jobs/ledger.rs b/src/jobs/ledger.rs index b0f18284..455a7db5 100644 --- a/src/jobs/ledger.rs +++ b/src/jobs/ledger.rs @@ -133,7 +133,7 @@ pub fn list_metas() -> Result> { } } - out.sort_by(|a, b| b.started_at.cmp(&a.started_at)); + out.sort_by_key(|a| std::cmp::Reverse(a.started_at)); Ok(out) } @@ -182,7 +182,7 @@ pub fn is_pid_alive(pid: u32) -> bool { } let err = std::io::Error::last_os_error(); // EPERM means the process exists but we can't signal it. - return err.raw_os_error() == Some(libc::EPERM); + err.raw_os_error() == Some(libc::EPERM) } #[cfg(windows)] { diff --git a/src/llm/client.rs b/src/llm/client.rs index 803b8882..cab1548d 100644 --- a/src/llm/client.rs +++ b/src/llm/client.rs @@ -406,6 +406,13 @@ pub(crate) fn hosted_search_args_are_hollow(args: &serde_json::Value) -> bool { } serde_json::Value::Object(map) if map.is_empty() => true, serde_json::Value::Object(map) => { + // Non-search tool args (read/list/grep/glob/…) must not look hollow — + // otherwise assistant_tool_part_info refuses to merge call args onto + // tool_result parts and exploration grouping falls apart. + const SEARCH_KEYS: &[&str] = &["query", "sources", "type", "limit"]; + if map.keys().any(|k| !SEARCH_KEYS.contains(&k.as_str())) { + return false; + } let query_empty = map .get("query") .and_then(|v| v.as_str()) @@ -3613,6 +3620,14 @@ mod tests { "query": "crabcode", "sources": [] }))); + // Local exploration tool args must never look hollow. + assert!(!super::hosted_search_args_are_hollow(&serde_json::json!({ + "pattern": "Explored", + "path": "src" + }))); + assert!(!super::hosted_search_args_are_hollow(&serde_json::json!({ + "file_path": "/repo/justfile" + }))); } #[test] diff --git a/src/main.rs b/src/main.rs index 05f72e52..2ea71481 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1278,8 +1278,10 @@ mod tests { let help = root_help().unwrap(); assert!(help.contains("Usage: crabcode")); - assert!(help.contains("completion Generate shell completion script")); - assert!(help.contains("serve Host the current workspace")); + assert!(help.contains("completion Generate shell completion script")); + assert!( + help.contains("serve Host the current workspace for browser and CLI clients") + ); } #[test] diff --git a/src/maintenance/mod.rs b/src/maintenance/mod.rs index 84bffebc..29b2b281 100644 --- a/src/maintenance/mod.rs +++ b/src/maintenance/mod.rs @@ -21,7 +21,7 @@ pub trait MaintenanceTask: Send + Sync { fn run(&self, opts: &RunOpts) -> Result; } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct RunOpts { /// If true, don't delete — just report what would happen. pub dry_run: bool, @@ -29,15 +29,6 @@ pub struct RunOpts { pub only: Option, } -impl Default for RunOpts { - fn default() -> Self { - Self { - dry_run: false, - only: None, - } - } -} - #[derive(Debug, Clone, Default)] pub struct TaskReport { pub task_id: String, diff --git a/src/tools/permission.rs b/src/tools/permission.rs index 90d8ff07..f5706135 100644 --- a/src/tools/permission.rs +++ b/src/tools/permission.rs @@ -1202,6 +1202,7 @@ mod tests { assert!(!policies.is_allowed("plan", "bash")); assert!(!policies.is_allowed("plan", "bash_output")); assert!(!policies.is_allowed("plan", "bash_kill")); + assert!(!policies.is_allowed("plan", "bash_restart")); assert!(!policies.is_allowed("plan", "terminal_session")); assert!(!policies.is_allowed("plan", "write")); assert!(!policies.is_allowed("plan", "write_files")); diff --git a/src/views/question_dialog.rs b/src/views/question_dialog.rs index 164683b8..19064788 100644 --- a/src/views/question_dialog.rs +++ b/src/views/question_dialog.rs @@ -2251,9 +2251,7 @@ mod tests { assert_eq!(request.current_index, 1); assert_eq!(request.response(), json!([[]])); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); let confirm_text = confirm_body_lines(request, &colors) .iter() .flat_map(|line| line.spans.iter()) @@ -2750,9 +2748,7 @@ mod tests { ]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); let line = question_tabs_line(&request, 0, &colors); let text: String = line .spans @@ -2778,9 +2774,7 @@ mod tests { }]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); let body = question_body_lines( &request.questions[0], &request.answers[0], @@ -2824,9 +2818,7 @@ mod tests { ]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); let body = question_body_lines( &request.questions[1], &request.answers[1], @@ -2865,9 +2857,7 @@ mod tests { for ch in "this is a long custom answer that should not be truncated".chars() { request.insert_char(ch); } - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); let body = confirm_body_lines(&request, &colors); let text = body .iter() @@ -2892,9 +2882,7 @@ mod tests { }]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); let line = question_tabs_line(&request, 0, &colors); assert_eq!(line.spans[0].content.as_ref(), " Question 1 "); @@ -2919,9 +2907,7 @@ mod tests { ]), tx, ); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); let line = footer_line(&request, &colors); let text: String = line .spans @@ -2949,9 +2935,7 @@ mod tests { assert!(request.questions[0].multiple); - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); let footer = footer_line(&request, &colors); let footer_text: String = footer .spans @@ -3319,9 +3303,7 @@ mod tests { label: "A".to_string(), description: String::new(), }; - let colors = crate::theme::Theme::load_from_file("src/theme.json") - .unwrap() - .get_colors(true); + let colors = crate::theme::Theme::load_builtin_default().get_colors(true); let line = option_line(&option, true, true, false, &colors); let text: String = line .spans From 94d1cc3a513a43b51a0e15e804a258456ea928c9 Mon Sep 17 00:00:00 2001 From: Blankeos Date: Fri, 28 Aug 2026 07:43:44 +0800 Subject: [PATCH 3/3] fix(better-bash): dont freeze when clicking outside the jobs dialog --- src/app.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index fd7b307a..8324689f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5066,7 +5066,13 @@ impl App { self.session_spinner_frame, ); self.handle_jobs_dialog_action(action); - if self.jobs_dialog_state.is_detail_open() { + // Click-outside hides via Dialog::handle_mouse_event → Handled, not Close. + // Mirror Sessions/Skills/Mcp: clear overlay when the dialog is gone, otherwise + // keys stay trapped in JobsDialog and the TUI looks frozen (ctrl-cc still works). + if !self.jobs_dialog_state.is_visible() { + self.selection_action_bar = None; + self.overlay_focus = OverlayFocus::None; + } else if self.jobs_dialog_state.is_detail_open() { match mouse.kind { MouseEventKind::Up(MouseButton::Left) if self.jobs_dialog_state.selection.active @@ -12464,6 +12470,30 @@ mod tests { } } + #[test] + fn jobs_dialog_click_outside_clears_overlay_focus() { + // Regression: Dialog::handle_mouse_event hides on outside click and + // returns Handled (not Close). Without clearing overlay_focus here, + // keys stay trapped in JobsDialog → TUI looks frozen (ctrl-cc still works). + let mut app = test_app(); + app.open_jobs_dialog(); + assert_eq!(app.overlay_focus, OverlayFocus::JobsDialog); + assert!(app.jobs_dialog_state.is_visible()); + + app.jobs_dialog_state.dialog.dialog_area = ratatui::layout::Rect::new(10, 5, 40, 12); + app.handle_mouse_event(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0)); + + assert!( + !app.jobs_dialog_state.is_visible(), + "click outside should hide the jobs dialog" + ); + assert_eq!( + app.overlay_focus, + OverlayFocus::None, + "overlay focus must clear so the input can receive keys again" + ); + } + #[test] fn selection_action_bar_column_mapping_matches_rendered_labels() { let chat_without_editor = SelectionActionBarState {