diff --git a/app/src/terminal/cli_agent_sessions/event/mod.rs b/app/src/terminal/cli_agent_sessions/event/mod.rs index e1fbf4d1635..a21ae2ddc0b 100644 --- a/app/src/terminal/cli_agent_sessions/event/mod.rs +++ b/app/src/terminal/cli_agent_sessions/event/mod.rs @@ -48,6 +48,18 @@ pub struct CLIAgentEventPayload { /// On Claude Code, this comes from the `StopFailure` hook (e.g. `"rate_limit"`). /// Not implemented for Codex. pub error_type: Option, + /// Background tasks and scheduled wakeups still pending when a `Stop` fired. Claude Code + /// wakes the agent again when any of them completes or fires, so a stop with pending work is + /// a pause, not the end of the session. `None` when the plugin predates this field. + pub pending_background_work_count: Option, +} + +impl CLIAgentEventPayload { + /// Whether the agent reported work that will wake it again without user input. + pub fn has_pending_background_work(&self) -> bool { + self.pending_background_work_count + .is_some_and(|count| count > 0) + } } /// A parsed event from a CLI agent plugin. diff --git a/app/src/terminal/cli_agent_sessions/event/v1.rs b/app/src/terminal/cli_agent_sessions/event/v1.rs index e16499431a4..dcd1a33bce8 100644 --- a/app/src/terminal/cli_agent_sessions/event/v1.rs +++ b/app/src/terminal/cli_agent_sessions/event/v1.rs @@ -39,6 +39,11 @@ pub(super) fn parse(body: &str) -> Option { .and_then(resolve_agent) .unwrap_or(CLIAgent::Unknown); + let pending_background_work_count = match (raw.background_task_count, raw.session_cron_count) { + (None, None) => None, + (tasks, crons) => Some(tasks.unwrap_or(0).saturating_add(crons.unwrap_or(0))), + }; + Some(CLIAgentEvent { v: raw.v.unwrap_or(1), agent, @@ -55,6 +60,7 @@ pub(super) fn parse(body: &str) -> Option { tool_input_preview, plugin_version: raw.plugin_version, error_type: raw.error_type, + pending_background_work_count, }, source: CLIAgentEventSource::RichPlugin, }) diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index 64cce01591b..f7433552868 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -249,6 +249,11 @@ impl CLIAgentSession { self.session_context.query = event.payload.query.clone(); self.session_context.response = event.payload.response.clone(); self.clear_permission_scoped_state(); + // The agent will wake itself again once that work finishes, so this turn + // boundary is a pause rather than the end of the session. + if event.payload.has_pending_background_work() { + return None; + } CLIAgentSessionStatus::Success } CLIAgentEventType::StopFailure => { diff --git a/app/src/terminal/cli_agent_sessions/mod_tests.rs b/app/src/terminal/cli_agent_sessions/mod_tests.rs index 3c9aa96eecf..e3c00cb49fb 100644 --- a/app/src/terminal/cli_agent_sessions/mod_tests.rs +++ b/app/src/terminal/cli_agent_sessions/mod_tests.rs @@ -850,6 +850,35 @@ fn needs_input_is_ignored_in_the_desktop_app() { assert_needs_input_blocks_session_in_mode(ExecutionMode::App, false); } +#[test] +fn stop_with_pending_background_work_keeps_session_in_progress() { + let body = r#"{"v":1,"agent":"claude","event":"stop","query":"build it","response":"Build started in the background.","background_task_count":1,"session_cron_count":2}"#; + let event = parse_event(Some("warp://cli-agent"), body).unwrap(); + assert_eq!(event.payload.pending_background_work_count, Some(3)); + assert!(event.payload.has_pending_background_work()); + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + + assert_eq!(session.apply_event(&event), None); + + assert_eq!(session.status, CLIAgentSessionStatus::InProgress); + assert_eq!( + session.session_context.response.as_deref(), + Some("Build started in the background.") + ); +} + +#[test] +fn stop_without_pending_background_work_succeeds() { + let body = r#"{"v":1,"agent":"claude","event":"stop","background_task_count":0,"session_cron_count":0}"#; + let event = parse_event(Some("warp://cli-agent"), body).unwrap(); + let mut session = cli_agent_session(CLIAgentSessionStatus::InProgress, true); + + assert_eq!( + session.apply_event(&event), + Some(CLIAgentSessionStatus::Success) + ); +} + // --- Ctrl-C pending-cancel state machine --- /// Grace window used by the tests below. Long enough to comfortably diff --git a/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs b/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs index 3d0843a18af..8cd2ebcc395 100644 --- a/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs +++ b/app/src/terminal/cli_agent_sessions/plugin_manager/claude.rs @@ -21,7 +21,7 @@ const MARKETPLACE_NAME: &str = "claude-code-warp"; // Keep in sync with the plugin version in warpdotdev/claude-code-warp. // (See the Versioning section of that repo's README.) -const MINIMUM_PLUGIN_VERSION: &str = "2.1.0"; +const MINIMUM_PLUGIN_VERSION: &str = "2.4.0"; // Keep in sync with the oz-harness-support plugin version in warpdotdev/claude-code-warp. const MINIMUM_PLATFORM_PLUGIN_VERSION: &str = "1.1.2"; diff --git a/crates/warp_core/src/cli_agent_protocol.rs b/crates/warp_core/src/cli_agent_protocol.rs index bc7b6cd42f5..a46516053cf 100644 --- a/crates/warp_core/src/cli_agent_protocol.rs +++ b/crates/warp_core/src/cli_agent_protocol.rs @@ -31,6 +31,10 @@ pub struct CLIAgentNotification { pub tool_input: Option, pub plugin_version: Option, pub error_type: Option, + /// Background tasks (shell, subagent, monitor, ...) still running when the agent stopped. + pub background_task_count: Option, + /// Session-scoped scheduled wakeups (cron, loop) pending when the agent stopped. + pub session_cron_count: Option, } impl CLIAgentNotification { @@ -50,6 +54,8 @@ impl CLIAgentNotification { tool_input: None, plugin_version: None, error_type: None, + background_task_count: None, + session_cron_count: None, } } }