From e0758c32118f2aa006db3d8fa4b41833fe6e7ead Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 17 Jul 2026 23:55:08 +0300 Subject: feat: harden named agent cli workflows refs #1525 --- src/api/client.rs | 67 +----- src/api/mod.rs | 1 + src/api/schema.rs | 2 + src/api/schema/agents.rs | 32 +-- src/api/schema/response.rs | 4 + src/api/schema/tests.rs | 35 +++ src/api/server.rs | 1 + src/app/actions.rs | 52 ++++- src/app/agents.rs | 360 ++++++++++++++---------------- src/app/api.rs | 1 + src/app/api/agents.rs | 185 +++++++++++++++- src/app/api/panes.rs | 51 +++-- src/app/api_helpers.rs | 29 +++ src/app/mod.rs | 99 +++++++-- src/app/runtime.rs | 16 ++ src/cli.rs | 45 +--- src/cli/agent.rs | 529 +++++++++++++++++++++++++++++++-------------- src/cli/spec.rs | 61 +++--- src/detect/mod.rs | 108 ++++++--- src/handoff_runtime.rs | 7 + src/persist/restore.rs | 35 ++- src/persist/snapshot.rs | 117 ++++++---- src/platform/fallback.rs | 8 + src/platform/linux.rs | 8 + src/platform/macos.rs | 8 + src/platform/mod.rs | 116 ++++++++++ src/platform/windows.rs | 188 ++++++++++++++++ src/protocol/wire.rs | 2 +- src/terminal/state.rs | 195 ++++++++++++++++- 29 files changed, 1741 insertions(+), 621 deletions(-) (limited to 'src') diff --git a/src/api/client.rs b/src/api/client.rs index 38baf807..326ebc7d 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -7,8 +7,7 @@ use interprocess::local_socket::traits::Stream as _; use serde::de::DeserializeOwned; use crate::api::schema::{ - ErrorResponse, EventsSubscribeParams, Method, PingParams, Request, ResponseResult, - SubscriptionEventEnvelope, SuccessResponse, + ErrorResponse, Method, PingParams, Request, ResponseResult, SuccessResponse, }; use crate::ipc::LocalStream; @@ -75,37 +74,6 @@ impl ApiClient { read_json_line(&mut reader) } - #[allow(dead_code)] // Kept as the typed subscription API; CLI wait paths use subscribe_value to preserve raw ack errors. - pub fn subscribe( - &self, - id: impl Into, - params: EventsSubscribeParams, - read_timeout: Option, - ) -> Result<(SuccessResponse, EventStream), ApiClientError> { - let request = Request { - id: id.into(), - method: Method::EventsSubscribe(params), - }; - let (ack, stream) = self.subscribe_value(&request, read_timeout)?; - Ok((parse_response_value(ack)?, stream)) - } - - pub fn subscribe_value( - &self, - request: &Request, - read_timeout: Option, - ) -> Result<(serde_json::Value, EventStream), ApiClientError> { - let mut stream = self.connect()?; - write_request(&mut stream, request)?; - if let Some(timeout) = read_timeout { - set_timeout_best_effort(&stream, TimeoutKind::Recv, timeout)?; - } - - let mut reader = BufReader::new(stream); - let ack = read_json_line(&mut reader)?; - Ok((ack, EventStream { reader })) - } - pub fn status(&self) -> Result { let response = self.request(Request { id: "api-client:status".into(), @@ -152,23 +120,6 @@ fn set_timeout_best_effort( } } -pub struct EventStream { - reader: BufReader, -} - -impl EventStream { - pub fn next_value(&mut self) -> Result, ApiClientError> { - read_optional_json_line(&mut self.reader) - } - - pub fn next_event(&mut self) -> Result, ApiClientError> { - self.next_value()? - .map(serde_json::from_value) - .transpose() - .map_err(ApiClientError::Json) - } -} - #[derive(Debug)] pub enum ApiClientError { Io(io::Error), @@ -222,22 +173,6 @@ fn read_json_line( serde_json::from_str(&line).map_err(ApiClientError::Json) } -fn read_optional_json_line( - reader: &mut BufReader, -) -> Result, ApiClientError> { - let mut line = String::new(); - let read = reader.read_line(&mut line)?; - if read == 0 { - return Ok(None); - } - if line.trim().is_empty() { - return Err(ApiClientError::EmptyResponse); - } - serde_json::from_str(&line) - .map(Some) - .map_err(ApiClientError::Json) -} - #[derive(serde::Deserialize)] #[serde(untagged)] enum WireResponse { diff --git a/src/api/mod.rs b/src/api/mod.rs index 364d5fc8..618fa4f9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -44,6 +44,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::AgentRename(_) | Method::AgentFocus(_) | Method::AgentStart(_) + | Method::AgentPrompt(_) | Method::PaneSplit(_) | Method::PaneSwap(_) | Method::PaneMove(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 4f5df34f..41f372e3 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -117,6 +117,8 @@ pub enum Method { AgentFocus(AgentTarget), #[serde(rename = "agent.start")] AgentStart(AgentStartParams), + #[serde(rename = "agent.prompt")] + AgentPrompt(AgentPromptParams), #[serde(rename = "pane.split")] PaneSplit(PaneSplitParams), #[serde(rename = "pane.swap")] diff --git a/src/api/schema/agents.rs b/src/api/schema/agents.rs index 969383c4..8592e56b 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use super::common::{AgentStatus, ReadFormat, ReadSource, SplitDirection}; +use super::common::{AgentStatus, ReadFormat, ReadSource}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct AgentReadParams { @@ -32,19 +32,19 @@ pub struct AgentRenameParams { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct AgentStartParams { pub name: String, + pub kind: String, + pub pane_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// Startup timeout in milliseconds. Values must be greater than 3000 and at most 300000. #[serde(default, skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tab_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub split: Option, - #[serde(default)] - pub focus: bool, - pub argv: Vec, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub env: HashMap, + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct AgentPromptParams { + pub target: String, + pub text: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -76,6 +76,12 @@ pub struct AgentInfo { pub tab_id: String, pub pane_id: String, pub focused: bool, + #[serde(default, skip_serializing_if = "super::is_false")] + pub launch_pending: bool, + #[serde(default, skip_serializing_if = "super::is_false")] + pub interactive_ready: bool, + #[serde(default)] + pub state_change_seq: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index 129c8f91..ad058191 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -101,6 +101,10 @@ pub enum ResponseResult { agent: AgentInfo, argv: Vec, }, + AgentPrompted { + agent: AgentInfo, + baseline_state_change_seq: u64, + }, AgentList { agents: Vec, }, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index bc044ee9..232c2b73 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -61,6 +61,41 @@ fn request_uses_dot_method_names() { assert_eq!(json["method"], "workspace.create"); } +#[test] +fn agent_start_and_prompt_requests_round_trip() { + let start = Request { + id: "start".into(), + method: Method::AgentStart(AgentStartParams { + name: "reviewer".into(), + kind: "pi".into(), + pane_id: "w1:p2".into(), + args: vec!["--no-session".into()], + timeout_ms: Some(30_000), + }), + }; + let start_json = serde_json::to_value(&start).unwrap(); + assert_eq!(start_json["method"], "agent.start"); + assert_eq!(start_json["params"]["pane_id"], "w1:p2"); + assert_eq!( + serde_json::from_value::(start_json).unwrap(), + start + ); + + let prompt = Request { + id: "prompt".into(), + method: Method::AgentPrompt(AgentPromptParams { + target: "reviewer".into(), + text: "review this".into(), + }), + }; + let prompt_json = serde_json::to_value(&prompt).unwrap(); + assert_eq!(prompt_json["method"], "agent.prompt"); + assert_eq!( + serde_json::from_value::(prompt_json).unwrap(), + prompt + ); +} + #[test] fn bundled_protocol_schema_refs_resolve_inside_bundle() { fn assert_no_standalone_refs(value: &serde_json::Value) { diff --git a/src/api/server.rs b/src/api/server.rs index d159e1dc..ce538853 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -371,6 +371,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::AgentRename(_) => "agent.rename", Method::AgentFocus(_) => "agent.focus", Method::AgentStart(_) => "agent.start", + Method::AgentPrompt(_) => "agent.prompt", Method::PaneSplit(_) => "pane.split", Method::PaneSwap(_) => "pane.swap", Method::PaneMove(_) => "pane.move", diff --git a/src/app/actions.rs b/src/app/actions.rs index 3fedeeb3..b4f352b4 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,6 +1,8 @@ //! Pure state mutations on AppState. //! These don't need channels, async, or PTY runtime. +use std::time::Instant; + use tracing::{info, warn}; use crate::detect::{Agent, AgentState}; @@ -2583,7 +2585,7 @@ impl AppState { observed_at, } => self .update_terminal_state(pane_id, |terminal| { - Some(terminal.set_detected_state_with_screen_signals_at( + let mutation = terminal.set_detected_state_with_screen_signals_at( agent, state, visible_blocker, @@ -2591,7 +2593,11 @@ impl AppState { visible_working, process_exited, observed_at, - )) + ); + if process_exited { + terminal.reconcile_managed_agent_at(observed_at, true); + } + Some(mutation) }) .into_iter() .collect(), @@ -2753,11 +2759,13 @@ impl AppState { .attached_terminal_id .clone(); let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; - let mutation = { + let (mutation, managed_changed) = { let terminal = self.terminals.get_mut(&terminal_id)?; - update(terminal)? + let mutation = update(terminal)?; + let managed_changed = terminal.reconcile_managed_agent_at(Instant::now(), false); + (mutation, managed_changed) }; - if mutation.session_ref_changed { + if mutation.session_ref_changed || managed_changed { self.mark_session_dirty(); } let change = mutation.effective_state_change?; @@ -2785,6 +2793,40 @@ impl AppState { Some(update) } + pub(crate) fn next_managed_agent_deadline(&self) -> Option { + self.terminals + .values() + .filter_map(crate::terminal::TerminalState::next_managed_agent_deadline) + .min() + } + + pub(crate) fn reconcile_managed_agents_at(&mut self, now: Instant) -> Vec<(usize, PaneId)> { + let mut changed_terminals = std::collections::HashSet::new(); + for (terminal_id, terminal) in &mut self.terminals { + if terminal.reconcile_managed_agent_at(now, false) { + changed_terminals.insert(terminal_id.clone()); + } + } + if changed_terminals.is_empty() { + return Vec::new(); + } + self.mark_session_dirty(); + self.workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, workspace)| { + let changed_terminals = &changed_terminals; + workspace.tabs.iter().flat_map(move |tab| { + tab.panes.iter().filter_map(move |(&pane_id, pane)| { + changed_terminals + .contains(&pane.attached_terminal_id) + .then_some((ws_idx, pane_id)) + }) + }) + }) + .collect() + } + pub(crate) fn publish_pane_process_exit_if_agent( &mut self, pane_id: PaneId, diff --git a/src/app/agents.rs b/src/app/agents.rs index 83f97bd4..f95bf77f 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -1,7 +1,13 @@ -use std::path::PathBuf; +use std::time::{Duration, Instant}; -use super::{terminal_targets::TerminalTargetError, App, Mode}; -use crate::api::schema::{AgentStartParams, SplitDirection}; +use bytes::Bytes; + +use super::{terminal_targets::TerminalTargetError, App}; +use crate::api::schema::AgentStartParams; + +const DEFAULT_AGENT_START_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_AGENT_START_TIMEOUT: Duration = Duration::from_secs(300); +const AGENT_START_SETTLE_DELAY: Duration = Duration::from_secs(3); impl App { pub(super) fn collect_agent_infos(&self) -> Vec { @@ -20,6 +26,31 @@ impl App { .collect() } + pub(super) fn reconcile_managed_agent_target(&mut self, target: &str) { + let Ok(resolved) = self.resolve_terminal_target(target) else { + return; + }; + let Some(terminal_id) = self + .state + .workspaces + .get(resolved.ws_idx) + .and_then(|workspace| workspace.terminal_id(resolved.pane_id)) + .cloned() + else { + return; + }; + let changed = self + .state + .terminals + .get_mut(&terminal_id) + .is_some_and(|terminal| terminal.reconcile_managed_agent_at(Instant::now(), false)); + if changed { + self.state.mark_session_dirty(); + self.schedule_session_save(); + self.emit_pane_updated(resolved.ws_idx, resolved.pane_id); + } + } + pub(super) fn agent_info_for_target( &self, target: &str, @@ -79,6 +110,9 @@ impl App { target: target.to_string(), })); }; + if terminal.managed_agent_launch_pending() { + return Err(AgentRenameError::PendingLaunch); + } match normalized_name { Some(name) => { terminal.set_agent_name(name.clone()); @@ -98,14 +132,20 @@ impl App { pub(super) fn start_agent( &mut self, params: AgentStartParams, - extra_env: Vec<(String, String)>, ) -> Result<(crate::api::schema::AgentInfo, Vec), AgentStartError> { let name = params.name.trim().to_string(); if name.is_empty() { return Err(AgentStartError::InvalidName); } - if params.argv.is_empty() { - return Err(AgentStartError::EmptyArgv); + let Some(kind) = crate::detect::parse_agent_label(¶ms.kind) else { + return Err(AgentStartError::UnsupportedKind(params.kind)); + }; + if params + .args + .iter() + .any(|arg| arg.chars().any(char::is_control)) + { + return Err(AgentStartError::InvalidArgument); } let conflicts = self.agent_name_conflicts(&name, ""); if !conflicts.is_empty() { @@ -114,94 +154,68 @@ impl App { candidates: conflicts, }); } - - let cwd = params - .cwd - .map(PathBuf::from) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from("/")); - let argv = params.argv; - let focus = params.focus; - let (rows, cols) = self.state.estimate_pane_size(); - - let (ws_idx, tab_idx, pane_id) = if let Some(tab_id) = params.tab_id { - let (ws_idx, tab_idx) = - self.parse_tab_id(&tab_id) - .ok_or_else(|| AgentStartError::TargetNotFound { - target: tab_id.clone(), - })?; - if let Some(workspace_id) = params.workspace_id.as_deref() { - let requested_ws_idx = self.parse_workspace_id(workspace_id).ok_or_else(|| { - AgentStartError::TargetNotFound { - target: workspace_id.to_string(), - } - })?; - if requested_ws_idx != ws_idx { - return Err(AgentStartError::PlacementConflict); - } - } - let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused(); - self.spawn_agent_split( - ws_idx, - target_pane, - params.split.unwrap_or(SplitDirection::Right), - cwd, - &argv, - extra_env, - focus, - )? - } else if let Some(workspace_id) = params.workspace_id { - let ws_idx = self.parse_workspace_id(&workspace_id).ok_or_else(|| { - AgentStartError::TargetNotFound { - target: workspace_id.clone(), - } - })?; - let tab_idx = self.state.workspaces[ws_idx].active_tab; - let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused(); - self.spawn_agent_split( - ws_idx, - target_pane, - params.split.unwrap_or(SplitDirection::Right), - cwd, - &argv, - extra_env, - focus, - )? - } else if self.state.workspaces.is_empty() { - self.spawn_agent_workspace(cwd, rows, cols, &argv, extra_env, focus)? - } else { - let ws_idx = self.state.active.unwrap_or(0); - let tab_idx = self.state.workspaces[ws_idx].active_tab; - let target_pane = self.state.workspaces[ws_idx].tabs[tab_idx].layout.focused(); - self.spawn_agent_split( - ws_idx, - target_pane, - params.split.unwrap_or(SplitDirection::Right), - cwd, - &argv, - extra_env, - focus, - )? + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return Err(AgentStartError::TargetNotFound(params.pane_id)); }; - let terminal_id = self .state .workspaces .get(ws_idx) - .and_then(|ws| ws.terminal_id(pane_id)) + .and_then(|workspace| workspace.terminal_id(pane_id)) .cloned() - .ok_or_else(|| AgentStartError::SpawnFailed("terminal disappeared".into()))?; - let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { - return Err(AgentStartError::SpawnFailed("terminal disappeared".into())); - }; - terminal.set_agent_name(name.clone()); + .ok_or_else(|| AgentStartError::TargetNotFound(params.pane_id.clone()))?; + let terminal = self + .state + .terminals + .get(&terminal_id) + .ok_or_else(|| AgentStartError::TargetNotFound(params.pane_id.clone()))?; + if terminal.is_agent_terminal() || terminal.managed_agent_kind().is_some() { + return Err(AgentStartError::TargetBusy(params.pane_id)); + } + let runtime = self + .terminal_runtimes + .get(&terminal_id) + .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?; + let shell_name = available_shell_name(runtime) + .ok_or_else(|| AgentStartError::TargetBusy(params.pane_id.clone()))?; + + let mut argv = vec![crate::detect::interactive_agent_executable(kind).to_string()]; + argv.extend(params.args); + let command = crate::platform::interactive_shell_command(&argv, &shell_name) + .ok_or(AgentStartError::InvalidArgument)?; + let bytes = crate::app::api_helpers::encode_api_submission(runtime, &command); + let timeout = Duration::from_millis( + params + .timeout_ms + .unwrap_or(DEFAULT_AGENT_START_TIMEOUT.as_millis() as u64), + ); + if timeout <= AGENT_START_SETTLE_DELAY || timeout > MAX_AGENT_START_TIMEOUT { + return Err(AgentStartError::InvalidTimeout); + } + + let previous_label = terminal.manual_label.clone(); + let now = Instant::now(); + let terminal = self + .state + .terminals + .get_mut(&terminal_id) + .ok_or_else(|| AgentStartError::TargetUnavailable(params.pane_id.clone()))?; + terminal.begin_managed_agent(name.clone(), kind, now, AGENT_START_SETTLE_DELAY, timeout); terminal.set_manual_label(name); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + terminal.clear_agent_name(); + match previous_label { + Some(label) => terminal.set_manual_label(label), + None => terminal.clear_manual_label(), + } + return Err(AgentStartError::InputFailed(err.to_string())); + } self.state.mark_session_dirty(); + self.schedule_session_save(); let agent = self .agent_info(ws_idx, pane_id) - .ok_or_else(|| AgentStartError::SpawnFailed("agent disappeared".into()))?; - debug_assert_eq!(agent.tab_id, self.public_tab_id(ws_idx, tab_idx).unwrap()); + .ok_or(AgentStartError::TargetUnavailable(params.pane_id))?; Ok((agent, argv)) } @@ -214,20 +228,33 @@ impl App { code: "invalid_agent_name".into(), message: "agent name must not be empty".into(), }, - AgentStartError::EmptyArgv => crate::api::schema::ErrorBody { - code: "invalid_agent_argv".into(), - message: "agent start argv must not be empty".into(), + AgentStartError::UnsupportedKind(kind) => crate::api::schema::ErrorBody { + code: "unsupported_agent_kind".into(), + message: format!("unsupported interactive agent kind {kind}"), }, - AgentStartError::TargetNotFound { target } => crate::api::schema::ErrorBody { - code: "agent_placement_not_found".into(), - message: format!("agent placement target {target} not found"), + AgentStartError::InvalidArgument => crate::api::schema::ErrorBody { + code: "invalid_agent_argument".into(), + message: "agent arguments cannot be encoded safely for the target shell".into(), }, - AgentStartError::PlacementConflict => crate::api::schema::ErrorBody { - code: "agent_placement_conflict".into(), - message: "--tab must belong to --workspace".into(), + AgentStartError::InvalidTimeout => crate::api::schema::ErrorBody { + code: "invalid_agent_timeout".into(), + message: "agent start timeout must be greater than 3000ms and at most 300000ms" + .into(), }, - AgentStartError::SpawnFailed(message) => crate::api::schema::ErrorBody { - code: "agent_start_failed".into(), + AgentStartError::TargetNotFound(target) => crate::api::schema::ErrorBody { + code: "agent_pane_not_found".into(), + message: format!("agent target pane {target} not found"), + }, + AgentStartError::TargetBusy(target) => crate::api::schema::ErrorBody { + code: "agent_pane_busy".into(), + message: format!("agent target pane {target} is not an available shell"), + }, + AgentStartError::TargetUnavailable(target) => crate::api::schema::ErrorBody { + code: "agent_pane_unavailable".into(), + message: format!("agent target pane {target} has no live terminal"), + }, + AgentStartError::InputFailed(message) => crate::api::schema::ErrorBody { + code: "agent_start_input_failed".into(), message, }, AgentStartError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody { @@ -291,6 +318,10 @@ impl App { ) -> crate::api::schema::ErrorBody { match err { AgentRenameError::Target(err) => self.agent_target_error_body(err), + AgentRenameError::PendingLaunch => crate::api::schema::ErrorBody { + code: "agent_launch_pending".into(), + message: "agent name cannot change while startup is pending".into(), + }, AgentRenameError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody { code: "agent_name_taken".into(), message: format!( @@ -313,99 +344,7 @@ impl App { } } - fn spawn_agent_workspace( - &mut self, - cwd: PathBuf, - rows: u16, - cols: u16, - argv: &[String], - extra_env: Vec<(String, String)>, - focus: bool, - ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { - let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command_with_extra_env( - cwd, - rows, - cols, - argv, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.event_tx.clone(), - self.render_notify.clone(), - self.render_dirty.clone(), - extra_env, - ) - .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.terminal_runtimes.insert(terminal.id.clone(), runtime); - self.state.terminals.insert(terminal.id.clone(), terminal); - self.state.workspaces.push(ws); - let ws_idx = self.state.workspaces.len() - 1; - self.state - .remove_alias_shadowed_by_new_pane(self.state.workspaces[ws_idx].tabs[0].root_pane); - if focus || self.state.active.is_none() { - self.state.switch_workspace(ws_idx); - self.state.mode = Mode::Terminal; - } - self.schedule_session_save(); - let pane_id = self.state.workspaces[ws_idx].tabs[0].root_pane; - Ok((ws_idx, 0, pane_id)) - } - - fn spawn_agent_split( - &mut self, - ws_idx: usize, - target_pane: crate::layout::PaneId, - split: SplitDirection, - cwd: PathBuf, - argv: &[String], - extra_env: Vec<(String, String)>, - focus: bool, - ) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> { - let (rows, cols) = self.state.estimate_pane_size(); - let previous_focus = self.state.current_pane_focus_target(); - let direction = match split { - SplitDirection::Right => ratatui::layout::Direction::Horizontal, - SplitDirection::Down => ratatui::layout::Direction::Vertical, - }; - let result = self - .state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| { - ws.split_pane_argv_command( - target_pane, - direction, - rows, - cols, - Some(cwd), - argv, - extra_env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - focus, - ) - }) - .ok_or_else(|| AgentStartError::TargetNotFound { - target: target_pane.raw().to_string(), - })? - .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; - self.terminal_runtimes - .insert(result.1.terminal.id.clone(), result.1.runtime); - self.state - .remove_alias_shadowed_by_new_pane(result.1.pane_id); - self.state - .terminals - .insert(result.1.terminal.id.clone(), result.1.terminal); - if focus { - self.state.switch_workspace_tab(ws_idx, result.0); - self.state - .record_pane_focus_change(previous_focus, ws_idx, result.1.pane_id); - self.state.mode = Mode::Terminal; - } - self.schedule_session_save(); - Ok((ws_idx, result.0, result.1.pane_id)) - } - - fn agent_info( + pub(super) fn agent_info( &self, ws_idx: usize, pane_id: crate::layout::PaneId, @@ -434,6 +373,9 @@ impl App { tab_id: pane.tab_id, pane_id: pane.pane_id, focused: pane.focused, + launch_pending: terminal.managed_agent_launch_pending(), + interactive_ready: terminal.managed_agent_interactive_ready(), + state_change_seq: terminal.last_agent_state_change_seq.unwrap_or(0), cwd: pane.cwd, foreground_cwd: pane.foreground_cwd, revision: pane.revision, @@ -454,14 +396,45 @@ impl App { } } +fn available_shell_name(runtime: &crate::terminal::TerminalRuntime) -> Option { + #[cfg(test)] + if runtime.child_pid().is_none() { + return Some("sh".into()); + } + crate::platform::available_pane_shell(runtime.child_pid()?) +} + +pub(super) fn runtime_hosts_agent( + runtime: &crate::terminal::TerminalRuntime, + expected: crate::detect::Agent, +) -> bool { + #[cfg(test)] + if runtime.child_pid().is_none() { + return true; + } + live_runtime_agent(runtime) == Some(expected) +} + +fn live_runtime_agent(runtime: &crate::terminal::TerminalRuntime) -> Option { + let job = crate::detect::foreground_job(runtime.child_pid()?)?; + crate::detect::identify_agent_in_job(&job) + .map(|(agent, _)| agent) + .or_else(|| { + job.processes + .iter() + .find_map(|process| crate::platform::process_agent_hint(process.pid)) + }) +} + pub(super) enum AgentStartError { InvalidName, - EmptyArgv, - TargetNotFound { - target: String, - }, - PlacementConflict, - SpawnFailed(String), + UnsupportedKind(String), + InvalidArgument, + InvalidTimeout, + TargetNotFound(String), + TargetBusy(String), + TargetUnavailable(String), + InputFailed(String), DuplicateName { name: String, candidates: Vec, @@ -470,6 +443,7 @@ pub(super) enum AgentStartError { pub(super) enum AgentRenameError { Target(TerminalTargetError), + PendingLaunch, DuplicateName { name: String, candidates: Vec, diff --git a/src/app/api.rs b/src/app/api.rs index 7bc8edf1..79402e47 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -995,6 +995,7 @@ impl App { Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target), Method::AgentRename(params) => return self.handle_agent_rename(request.id, params), Method::AgentStart(params) => return self.handle_agent_start(request.id, params), + Method::AgentPrompt(params) => return self.handle_agent_prompt(request.id, params), Method::AgentRead(params) => return self.handle_agent_read(request.id, params), Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target), Method::AgentSend(params) => return self.handle_agent_send(request.id, params), diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index b66924ec..d1fbf14c 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -1,8 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, PaneReadResult, ReadFormat, - ReadSource, ResponseResult, + AgentPromptParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, + PaneReadResult, ReadFormat, ReadSource, ResponseResult, }; use crate::app::App; @@ -19,6 +19,7 @@ impl App { } pub(super) fn handle_agent_get(&mut self, id: String, target: AgentTarget) -> String { + self.reconcile_managed_agent_target(&target.target); let agent = match self.agent_info_for_target(&target.target) { Ok(agent) => agent, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), @@ -46,11 +47,7 @@ impl App { } pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String { - let extra_env = match super::env::normalize_launch_env(params.env.clone()) { - Ok(env) => env, - Err((code, message)) => return encode_error(id, &code, message), - }; - let (agent, argv) = match self.start_agent(params, extra_env) { + let (agent, argv) = match self.start_agent(params) { Ok(started) => started, Err(err) => return encode_error_body(id, self.agent_start_error_body(err)), }; @@ -58,6 +55,78 @@ impl App { encode_success(id, ResponseResult::AgentStarted { agent, argv }) } + pub(super) fn handle_agent_prompt(&mut self, id: String, params: AgentPromptParams) -> String { + if params.text.is_empty() { + return encode_error(id, "empty_agent_prompt", "agent prompt must not be empty"); + } + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + let Some(terminal_id) = self + .state + .workspaces + .get(resolved.ws_idx) + .and_then(|workspace| workspace.terminal_id(resolved.pane_id)) + .cloned() + else { + return agent_not_found(id, ¶ms.target); + }; + let Some(terminal) = self.state.terminals.get(&terminal_id) else { + return agent_not_found(id, ¶ms.target); + }; + let Some(expected_agent) = terminal.effective_known_agent().filter(|_| { + terminal.agent_name.as_deref() == Some(params.target.as_str()) + && terminal.state != crate::detect::AgentState::Unknown + }) else { + return agent_not_ready(id, ¶ms.target); + }; + if terminal.managed_agent_launch_pending() { + return agent_not_ready(id, ¶ms.target); + } + if terminal.state == crate::detect::AgentState::Working { + return encode_error( + id, + "agent_working", + format!( + "agent {} is still working; wait before prompting", + params.target + ), + ); + } + if terminal.managed_agent_kind().is_some() && !terminal.managed_agent_interactive_ready() { + return agent_not_ready(id, ¶ms.target); + } + let baseline_state_change_seq = terminal.last_agent_state_change_seq.unwrap_or(0); + let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { + return agent_not_found(id, ¶ms.target); + }; + if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) { + return encode_error( + id, + "agent_not_ready", + format!( + "agent {} is no longer the pane foreground process", + params.target + ), + ); + } + let bytes = crate::app::api_helpers::encode_api_submission(runtime, ¶ms.text); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "agent_prompt_failed", err.to_string()); + } + let Some(agent) = self.agent_info(resolved.ws_idx, resolved.pane_id) else { + return agent_not_found(id, ¶ms.target); + }; + encode_success( + id, + ResponseResult::AgentPrompted { + agent, + baseline_state_change_seq, + }, + ) + } + pub(super) fn handle_agent_read( &mut self, id: String, @@ -195,6 +264,14 @@ impl App { } } +fn agent_not_ready(id: String, target: &str) -> String { + encode_error( + id, + "agent_not_ready", + format!("agent {target} is not an active named agent"), + ) +} + fn agent_not_found(id: String, target: &str) -> String { encode_error( id, @@ -231,6 +308,100 @@ mod tests { app } + #[tokio::test] + async fn agent_prompt_brackets_opencode_text_and_falls_back_to_raw_submission() { + let mut app = app_with_agent(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_agent_name("reviewer".into()); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + let (runtime, mut rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, 24, 0, b"", 1, + ); + runtime.test_process_pty_bytes(b"\x1b[?2004h"); + app.state.insert_test_runtime(pane_id, runtime); + + let response = app.handle_agent_prompt( + "req".into(), + AgentPromptParams { + target: "reviewer".into(), + text: "A != B".into(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::AgentPrompted { agent, .. } = success.result else { + panic!("expected prompted response"); + }; + assert_eq!(agent.name.as_deref(), Some("reviewer")); + assert_eq!( + rx.try_recv().unwrap(), + Bytes::from_static(b"\x1b[200~A != B\x1b[201~\r") + ); + assert!(rx.try_recv().is_err()); + + app.lookup_runtime_sender(0, pane_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2004l"); + let raw = app.handle_agent_prompt( + "req-raw".into(), + AgentPromptParams { + target: "reviewer".into(), + text: "A != B".into(), + }, + ); + let raw: SuccessResponse = serde_json::from_str(&raw).unwrap(); + assert!(matches!(raw.result, ResponseResult::AgentPrompted { .. })); + assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"A != B\r")); + assert!(rx.try_recv().is_err()); + + let rejected = app.handle_agent_prompt( + "req-label".into(), + AgentPromptParams { + target: "opencode".into(), + text: "wrong target".into(), + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); + assert_eq!(error.error.code, "agent_not_ready"); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn agent_prompt_rejects_managed_agent_while_startup_is_pending() { + let mut app = app_with_agent(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + let now = std::time::Instant::now(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::OpenCode, + now, + std::time::Duration::from_secs(3), + std::time::Duration::from_secs(10), + ); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.state.insert_test_runtime(pane_id, runtime); + + let response = app.handle_agent_prompt( + "req-pending".into(), + AgentPromptParams { + target: "reviewer".into(), + text: "A != B".into(), + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "agent_not_ready"); + assert!(rx.try_recv().is_err()); + } + #[test] fn agent_focus_marks_already_focused_done_agent_seen() { let mut app = app_with_agent(); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 17569682..6a5b41fe 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -20,9 +20,8 @@ use crate::app::Mode; use crate::layout::{find_in_direction, NavDirection, PaneId}; use super::super::api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_metadata_source, - normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label, - MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, + detect_state_from_api, encode_api_keys, normalize_metadata_source, normalize_metadata_tokens, + normalize_metadata_ttl, normalize_reported_agent_label, MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, }; #[cfg(test)] use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS}; @@ -1499,20 +1498,16 @@ impl App { let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; - let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { - Ok(encoded_keys) => encoded_keys, + let bytes = match super::super::api_helpers::encode_api_input( + runtime, + ¶ms.text, + ¶ms.keys, + ) { + Ok(bytes) => bytes, Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), }; - if !params.text.is_empty() { - let text_bytes = encode_api_text(runtime, ¶ms.text); - if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) { - return encode_error(id, "pane_send_failed", err.to_string()); - } - } - for bytes in encoded_keys { - if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { - return encode_error(id, "pane_send_failed", err.to_string()); - } + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); } encode_success(id, ResponseResult::Ok {}) @@ -2071,6 +2066,32 @@ mod tests { assert!(rx.try_recv().is_err()); } + #[tokio::test] + async fn api_pane_send_input_brackets_text_and_enter_atomically() { + let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); + let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.lookup_runtime_sender(0, internal_pane_id) + .unwrap() + .test_process_pty_bytes(b"\x1b[?2004h"); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req".into(), + method: crate::api::schema::Method::PaneSendInput(PaneSendInputParams { + pane_id, + text: "A != B".into(), + keys: vec!["Enter".into()], + }), + }); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.result, ResponseResult::Ok {}); + assert_eq!( + rx.try_recv().unwrap(), + bytes::Bytes::from_static(b"\x1b[200~A != B\x1b[201~\r") + ); + assert!(rx.try_recv().is_err()); + } + #[tokio::test] async fn api_pane_send_input_keys_accept_key_combo_chords() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c0a98357..c7dc39d2 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -48,6 +48,35 @@ pub(super) fn encode_api_keys( Ok(encoded_keys) } +pub(super) fn encode_api_submission( + runtime: &crate::terminal::TerminalRuntime, + text: &str, +) -> Vec { + let mut bytes = encode_api_text(runtime, text); + let enter = crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + ); + bytes.extend_from_slice(&runtime.encode_terminal_key(enter.into())); + bytes +} + +pub(super) fn encode_api_input( + runtime: &crate::terminal::TerminalRuntime, + text: &str, + keys: &[String], +) -> Result, String> { + let mut bytes = if text.is_empty() { + Vec::new() + } else { + encode_api_text(runtime, text) + }; + for encoded in encode_api_keys(runtime, keys)? { + bytes.extend_from_slice(&encoded); + } + Ok(bytes) +} + pub(super) fn detect_state_from_api( state: crate::api::schema::PaneAgentState, ) -> crate::detect::AgentState { diff --git a/src/app/mod.rs b/src/app/mod.rs index 578ea0cd..b6f2bbb8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4146,42 +4146,103 @@ mod tests { } #[tokio::test] - async fn focused_agent_start_records_previous_pane() { + async fn unavailable_agent_start_does_not_mutate_topology() { let mut app = test_app(); - let workspace = Workspace::test_new("agent-start-focus"); + let workspace = Workspace::test_new("agent-start-target"); let root = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; app.state.ensure_test_terminals(); app.state.active = Some(0); app.state.selected = 0; + let pane_id = app.pane_info(0, root).unwrap().pane_id; let response = app.handle_api_request(crate::api::schema::Request { - id: "req_agent_start_focus".into(), + id: "req_agent_start_target".into(), method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams { name: "worker".into(), - cwd: None, - workspace_id: None, - tab_id: None, - split: Some(crate::api::schema::SplitDirection::Right), - focus: true, - argv: vec![exiting_test_command().into()], - env: Default::default(), + kind: "pi".into(), + pane_id, + args: Vec::new(), + timeout_ms: Some(1_000), }), }); let response: serde_json::Value = serde_json::from_str(&response).unwrap(); - assert_eq!(response["result"]["type"], "agent_started"); - assert_ne!(app.state.workspaces[0].focused_pane_id(), Some(root)); + assert_eq!(response["error"]["code"], "agent_pane_unavailable"); + assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1); + assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); + } - app.state.last_pane(); + #[tokio::test] + async fn failed_agent_start_input_rolls_back_and_can_retry() { + let mut app = test_app(); + let workspace = Workspace::test_new("agent-start-input-failure"); + let root = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + let pane_id = app.pane_info(0, root).unwrap().pane_id; + let terminal_id = app.state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_manual_label("shell".into()); + let (runtime, mut receiver) = + crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, 1); + runtime + .try_send_bytes(bytes::Bytes::from_static(b"occupied")) + .unwrap(); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); + let request = || crate::api::schema::Request { + id: "req_agent_start_input".into(), + method: crate::api::schema::Method::AgentStart(crate::api::schema::AgentStartParams { + name: "worker".into(), + kind: "pi".into(), + pane_id: pane_id.clone(), + args: Vec::new(), + timeout_ms: Some(4_000), + }), + }; + let response = app.handle_api_request(request()); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["error"]["code"], "agent_start_input_failed"); + assert_eq!(app.state.terminals[&terminal_id].agent_name, None); + assert_eq!( + app.state.terminals[&terminal_id].manual_label.as_deref(), + Some("shell") + ); - let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); - for (_terminal_id, runtime) in runtimes { - runtime.shutdown(); - } + assert_eq!( + receiver.try_recv().unwrap(), + bytes::Bytes::from_static(b"occupied") + ); + let retry = app.handle_api_request(request()); + let retry: serde_json::Value = serde_json::from_str(&retry).unwrap(); + assert_eq!(retry["result"]["type"], "agent_started"); + assert_eq!( + app.state.terminals[&terminal_id].agent_name.as_deref(), + Some("worker") + ); + let rename = app.handle_api_request(crate::api::schema::Request { + id: "req_agent_rename_pending".into(), + method: crate::api::schema::Method::AgentRename( + crate::api::schema::AgentRenameParams { + target: pane_id, + name: Some("replacement".into()), + }, + ), + }); + let rename: serde_json::Value = serde_json::from_str(&rename).unwrap(); + assert_eq!(rename["error"]["code"], "agent_launch_pending"); + assert_eq!( + app.state.terminals[&terminal_id].agent_name.as_deref(), + Some("worker") + ); } #[test] diff --git a/src/app/runtime.rs b/src/app/runtime.rs index cad49372..97edc471 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -260,6 +260,21 @@ impl App { } } + if self + .state + .next_managed_agent_deadline() + .is_some_and(|deadline| now >= deadline) + { + let panes = self.state.reconcile_managed_agents_at(now); + if !panes.is_empty() { + for (ws_idx, pane_id) in panes { + self.emit_pane_updated(ws_idx, pane_id); + } + self.schedule_session_save(); + changed = true; + } + } + if self .copy_feedback_deadline .is_some_and(|deadline| now >= deadline) @@ -593,6 +608,7 @@ impl App { self.config_diagnostic_deadline, self.toast_deadline, self.state.next_pending_agent_notification_deadline(), + self.state.next_managed_agent_deadline(), self.copy_feedback_deadline, self.next_animation_tick, include_git_refresh diff --git a/src/cli.rs b/src/cli.rs index e59be830..ecca934e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; @@ -932,42 +932,6 @@ fn wait_for_agent_status_change( } } -pub(super) fn wait_for_agent_change( - request: Request, - timeout_ms: Option, - timeout_message: &str, -) -> std::io::Result { - let read_timeout = timeout_ms.map(Duration::from_millis); - let client = ApiClient::local(); - ensure_server_protocol_compatible(&client, &request.id)?; - let (ack, mut stream) = client - .subscribe_value(&request, read_timeout) - .map_err(api_client_error_to_io)?; - if let Err(err) = crate::api::client::parse_response_value(ack) { - if let ApiClientError::ErrorResponse(response) = err { - eprintln!("{}", serde_json::to_string(&response).unwrap()); - return Ok(1); - } - return Err(api_client_error_to_io(err)); - } - - match stream.next_event() { - Ok(None) => { - eprintln!("subscription closed before event arrived"); - Ok(1) - } - Ok(Some(event_value)) => { - println!("{}", serde_json::to_string(&event_value).unwrap()); - Ok(0) - } - Err(ApiClientError::Io(err)) if api_timeout_error(&err) => { - eprintln!("{timeout_message}"); - Ok(1) - } - Err(err) => Err(api_client_error_to_io(err)), - } -} - pub(super) fn print_response(response: &serde_json::Value) -> std::io::Result { if response.get("error").is_some() { eprintln!("{}", serde_json::to_string(response).unwrap()); @@ -1030,13 +994,6 @@ pub(crate) fn protocol_mismatch_was_reported(err: &std::io::Error) -> bool { protocol_guard::was_reported(err) } -fn api_timeout_error(err: &std::io::Error) -> bool { - matches!( - err.kind(), - std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock - ) -} - fn api_client_error_to_io(err: ApiClientError) -> std::io::Error { match err { ApiClientError::Io(err) => err, diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 80148046..898de33c 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -1,8 +1,8 @@ -use std::collections::HashMap; +use std::time::{Duration, Instant}; use crate::api::schema::{ - AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus, - AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, Subscription, + AgentPromptParams, AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, + AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, }; pub(super) fn run_agent_command(args: &[String]) -> std::io::Result { @@ -16,6 +16,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result { "get" => agent_get(&args[1..]), "read" => agent_read(&args[1..]), "send" => agent_send(&args[1..]), + "prompt" => agent_prompt(&args[1..]), "rename" => agent_rename(&args[1..]), "focus" => agent_focus(&args[1..]), "wait" => agent_wait(&args[1..]), @@ -269,82 +270,41 @@ fn matched_rule_region_preview<'a>( fn agent_start(args: &[String]) -> std::io::Result { let Some(name) = args.first() else { - eprintln!("usage: herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- "); + eprintln!("usage: herdr agent start --kind KIND --pane ID [--timeout MS] [-- ]"); return Ok(2); }; - - let Some(separator) = args.iter().position(|arg| arg == "--") else { - eprintln!("usage: herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- "); - return Ok(2); - }; - if separator == args.len() - 1 { - eprintln!("agent start requires argv after --"); - return Ok(2); - } - - let mut cwd = None; - let mut workspace_id = None; - let mut tab_id = None; - let mut split = None; - let mut focus = false; - let mut env = HashMap::new(); - + let separator = args + .iter() + .position(|arg| arg == "--") + .unwrap_or(args.len()); + let mut kind = None; + let mut pane_id = None; + let mut timeout_ms = None; let mut index = 1; while index < separator { match args[index].as_str() { - "--cwd" => { - let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --cwd"); - return Ok(2); - }; - cwd = Some(value.clone()); - index += 2; - } - "--workspace" => { - let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --workspace"); - return Ok(2); - }; - workspace_id = Some(super::normalize_workspace_id(value)); - index += 2; - } - "--tab" => { + "--kind" => { let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --tab"); + eprintln!("missing value for --kind"); return Ok(2); }; - tab_id = Some(super::normalize_tab_id(value)); + kind = Some(value.clone()); index += 2; } - "--split" => { + "--pane" => { let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --split"); + eprintln!("missing value for --pane"); return Ok(2); }; - split = Some(super::parse_split_direction(value)?); + pane_id = Some(super::normalize_pane_id(value)); index += 2; } - "--focus" => { - focus = true; - index += 1; - } - "--no-focus" => { - focus = false; - index += 1; - } - "--env" => { + "--timeout" => { let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else { - eprintln!("missing value for --env"); + eprintln!("missing value for --timeout"); return Ok(2); }; - let (key, value) = match super::parse_env_assignment(value) { - Ok(pair) => pair, - Err(err) => { - eprintln!("{err}"); - return Ok(2); - } - }; - env.insert(key, value); + timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); index += 2; } other => { @@ -353,20 +313,63 @@ fn agent_start(args: &[String]) -> std::io::Result { } } } - - super::print_response(&super::send_request(&Request { + let Some(kind) = kind else { + eprintln!("missing required --kind"); + return Ok(2); + }; + let Some(pane_id) = pane_id else { + eprintln!("missing required --pane"); + return Ok(2); + }; + let Some(expected_kind) = crate::detect::parse_agent_label(&kind) else { + eprintln!("unsupported interactive agent kind: {kind}"); + return Ok(2); + }; + let expected_kind = crate::detect::agent_label(expected_kind).to_string(); + let mut response = super::send_request(&Request { id: "cli:agent:start".into(), method: Method::AgentStart(AgentStartParams { name: name.clone(), - cwd, - workspace_id, - tab_id, - split, - focus, - argv: args[separator + 1..].to_vec(), - env, + kind, + pane_id: pane_id.clone(), + args: if separator < args.len() { + args[separator + 1..].to_vec() + } else { + Vec::new() + }, + timeout_ms, }), - })?) + })?; + if response.get("error").is_some() { + return super::print_response(&response); + } + let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { + return super::print_response(&cli_agent_error( + "cli:agent:start", + "agent_start_failed", + "agent start response did not include terminal_id", + )); + }; + let terminal_id = terminal_id.to_string(); + let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000)); + let waited = wait_for_named_agent( + &terminal_id, + name, + Some(timeout), + AgentWaitMode::Start { + expected_kind: &expected_kind, + }, + ); + match waited { + Ok(Ok(agent)) => { + response["result"]["agent"] = agent; + super::print_response(&response) + } + Ok(Err(error)) => super::print_response(&error), + Err(err) => { + print_agent_transport_error(err, "cli:agent:start", "agent_start_transport_failed") + } + } } fn agent_list(args: &[String]) -> std::io::Result { @@ -439,24 +442,13 @@ fn agent_attach(args: &[String]) -> std::io::Result { fn agent_wait(args: &[String]) -> std::io::Result { let Some(target) = args.first() else { - eprintln!("usage: herdr agent wait --status [--timeout MS]"); + eprintln!("usage: herdr agent wait [--timeout MS]"); return Ok(2); }; - let mut timeout_ms = None; - let mut desired_status = None; - let mut index = 1; while index < args.len() { match args[index].as_str() { - "--status" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --status"); - return Ok(2); - }; - desired_status = Some(parse_agent_wait_status(value)?); - index += 2; - } "--timeout" => { let Some(value) = args.get(index + 1) else { eprintln!("missing value for --timeout"); @@ -466,7 +458,7 @@ fn agent_wait(args: &[String]) -> std::io::Result { index += 2; } "help" | "--help" | "-h" => { - eprintln!("usage: herdr agent wait --status [--timeout MS]"); + eprintln!("usage: herdr agent wait [--timeout MS]"); return Ok(0); } other => { @@ -475,67 +467,234 @@ fn agent_wait(args: &[String]) -> std::io::Result { } } } - - let Some(agent_status) = desired_status else { - eprintln!("missing required --status"); - return Ok(2); + let initial = resolve_agent_target(target, "cli:agent:wait:resolve")?; + if initial.get("error").is_some() { + return super::print_response(&initial); + } + let agent = &initial["result"]["agent"]; + if agent["name"].as_str() != Some(target) { + return super::print_response(&cli_agent_error( + "cli:agent:wait", + "agent_name_not_found", + format!("named agent {target} not found"), + )); + } + match agent["agent_status"].as_str().unwrap_or("unknown") { + "idle" | "done" | "blocked" => return super::print_response(&initial), + "unknown" => { + return super::print_response(&cli_agent_error( + "cli:agent:wait", + "agent_not_running", + "agent is no longer running", + )) + } + _ => {} + } + let Some(terminal_id) = agent["terminal_id"].as_str() else { + return super::print_response(&cli_agent_error( + "cli:agent:wait", + "agent_wait_failed", + "agent response did not include terminal_id", + )); }; - - let response = resolve_agent_target(target, "cli:agent:wait:resolve")?; - if response.get("error").is_some() { - eprintln!("{}", serde_json::to_string(&response).unwrap()); - return Ok(1); + let timeout = timeout_ms.map(Duration::from_millis); + let waited = match wait_for_named_agent(terminal_id, target, timeout, AgentWaitMode::Current) { + Ok(waited) => waited, + Err(err) => { + return print_agent_transport_error( + err, + "cli:agent:wait", + "agent_wait_transport_failed", + ) + } + }; + match waited { + Ok(agent) => super::print_response(&serde_json::json!({ + "id": "cli:agent:wait", + "result": { "type": "agent_info", "agent": agent } + })), + Err(error) => super::print_response(&error), } - if response["result"]["agent"]["agent_status"] - .as_str() - .is_some_and(|current| agent_wait_status_satisfied(agent_status, current)) - { - println!("{}", serde_json::to_string(&response).unwrap()); - return Ok(0); +} + +#[derive(Clone, Copy)] +enum AgentWaitMode<'a> { + Start { expected_kind: &'a str }, + Current, + AfterPrompt { baseline_state_change_seq: u64 }, +} + +fn wait_for_named_agent( + lookup_target: &str, + expected_name: &str, + timeout: Option, + mode: AgentWaitMode<'_>, +) -> std::io::Result> { + let started_at = Instant::now(); + let deadline = timeout.and_then(|timeout| started_at.checked_add(timeout)); + let mut first_poll = true; + loop { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + if matches!(mode, AgentWaitMode::Start { .. }) { + // Let the server reconcile its matching startup deadline before + // returning so the pending name is immediately reusable. + let _ = resolve_agent_target_unchecked(lookup_target, "cli:agent:start:timeout"); + } + return Ok(Err(agent_wait_timeout(mode))); + } + let poll_id = match mode { + AgentWaitMode::Start { .. } => "cli:agent:start", + AgentWaitMode::Current => "cli:agent:wait", + AgentWaitMode::AfterPrompt { .. } => "cli:agent:prompt", + }; + let response = if first_poll { + first_poll = false; + resolve_agent_target(lookup_target, poll_id)? + } else { + resolve_agent_target_unchecked(lookup_target, poll_id)? + }; + if response.get("error").is_some() { + if matches!(mode, AgentWaitMode::Start { .. }) { + return Ok(Err(cli_agent_error( + "cli:agent:start", + "agent_start_failed", + "agent target disappeared before becoming interactive", + ))); + } + return Ok(Err(response)); + } + let agent = &response["result"]["agent"]; + let status = agent["agent_status"].as_str().unwrap_or("unknown"); + let completed = matches!(status, "idle" | "done" | "blocked"); + if !matches!(mode, AgentWaitMode::Start { .. }) + && agent["name"].as_str() != Some(expected_name) + { + return Ok(Err(agent_name_lost_error(poll_id, expected_name))); + } + let outcome = match mode { + AgentWaitMode::Start { expected_kind } => { + if let Some(actual) = agent["agent"].as_str() { + if actual != expected_kind { + Some(Err(cli_agent_error( + "cli:agent:start", + "agent_kind_mismatch", + format!("expected {expected_kind}, detected {actual}"), + ))) + } else if agent["name"].as_str() != Some(expected_name) { + Some(Err(agent_name_lost_error("cli:agent:start", expected_name))) + } else if completed && agent["interactive_ready"].as_bool().unwrap_or(false) { + Some(Ok(agent.clone())) + } else if !agent["launch_pending"].as_bool().unwrap_or(false) { + Some(Err(cli_agent_error( + "cli:agent:start", + "agent_start_failed", + "agent process exited before becoming interactive", + ))) + } else { + None + } + } else if !agent["launch_pending"].as_bool().unwrap_or(false) { + Some(Err(cli_agent_error( + "cli:agent:start", + "agent_start_failed", + "agent process exited before becoming interactive", + ))) + } else { + None + } + } + AgentWaitMode::Current => { + if completed { + Some(Ok(agent.clone())) + } else if status == "unknown" { + Some(Err(cli_agent_error( + "cli:agent:wait", + "agent_not_running", + "agent is no longer running", + ))) + } else { + None + } + } + AgentWaitMode::AfterPrompt { + baseline_state_change_seq, + } => { + let sequence = agent["state_change_seq"].as_u64().unwrap_or(0); + if sequence > baseline_state_change_seq && completed { + Some(Ok(agent.clone())) + } else if status == "unknown" { + Some(Err(cli_agent_error( + "cli:agent:prompt", + "agent_not_running", + "agent exited while waiting for the prompt", + ))) + } else { + None + } + } + }; + if let Some(outcome) = outcome { + return Ok(outcome); + } + std::thread::sleep(Duration::from_millis(100)); } +} - let Some(pane_id) = response["result"]["agent"]["pane_id"].as_str() else { - eprintln!("agent wait failed: response did not include pane_id"); +fn agent_name_lost_error(request_id: &str, expected_name: &str) -> serde_json::Value { + cli_agent_error( + request_id, + "agent_name_not_found", + format!("named agent {expected_name} no longer owns the target terminal"), + ) +} + +fn print_agent_transport_error( + err: std::io::Error, + request_id: &str, + code: &str, +) -> std::io::Result { + if super::protocol_mismatch_was_reported(&err) { return Ok(1); - }; + } + super::print_response(&cli_agent_error(request_id, code, err.to_string())) +} - let subscriptions = if agent_status == AgentStatus::Idle { - vec![ - Subscription::PaneAgentStatusChanged { - pane_id: pane_id.to_owned(), - agent_status: Some(AgentStatus::Idle), - }, - Subscription::PaneAgentStatusChanged { - pane_id: pane_id.to_owned(), - agent_status: Some(AgentStatus::Done), - }, - ] - } else { - vec![Subscription::PaneAgentStatusChanged { - pane_id: pane_id.to_owned(), - agent_status: Some(agent_status), - }] +fn agent_wait_timeout(mode: AgentWaitMode<'_>) -> serde_json::Value { + let (id, message) = match mode { + AgentWaitMode::Start { .. } => ("cli:agent:start", "timed out waiting for agent startup"), + AgentWaitMode::Current => ("cli:agent:wait", "timed out waiting for agent completion"), + AgentWaitMode::AfterPrompt { .. } => { + ("cli:agent:prompt", "timed out waiting for prompted work") + } }; + cli_agent_error(id, "timeout", message) +} - super::wait_for_agent_change( - Request { - id: "cli:agent:wait".into(), - method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams { - subscriptions, - }), - }, - timeout_ms, - "timed out waiting for agent status change", - ) +fn cli_agent_error(id: &str, code: &str, message: impl Into) -> serde_json::Value { + serde_json::json!({ + "id": id, + "error": { "code": code, "message": message.into() } + }) } fn resolve_agent_target(target: &str, request_id: &str) -> std::io::Result { - super::send_request(&Request { + super::send_request(&agent_get_request(target, request_id)) +} + +fn resolve_agent_target_unchecked( + target: &str, + request_id: &str, +) -> std::io::Result { + super::send_request_unchecked(&agent_get_request(target, request_id)) +} + +fn agent_get_request(target: &str, request_id: &str) -> Request { + Request { id: request_id.into(), method: Method::AgentGet(AgentTarget { target: target.to_owned(), }), - }) + } } fn agent_rename(args: &[String]) -> std::io::Result { @@ -562,6 +721,84 @@ fn agent_rename(args: &[String]) -> std::io::Result { })?) } +fn agent_prompt(args: &[String]) -> std::io::Result { + let Some(target) = args.first() else { + eprintln!("usage: herdr agent prompt [--wait] [--timeout MS]"); + return Ok(2); + }; + let Some(text) = args.get(1) else { + eprintln!("agent prompt requires text"); + return Ok(2); + }; + let mut wait = false; + let mut timeout_ms = None; + let mut index = 2; + while index < args.len() { + match args[index].as_str() { + "--wait" => { + wait = true; + index += 1; + } + "--timeout" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --timeout"); + return Ok(2); + }; + timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); + index += 2; + } + option => { + eprintln!("unknown option: {option}"); + return Ok(2); + } + } + } + let mut response = super::send_request(&Request { + id: "cli:agent:prompt".into(), + method: Method::AgentPrompt(AgentPromptParams { + target: target.clone(), + text: text.clone(), + }), + })?; + if response.get("error").is_some() || !wait { + return super::print_response(&response); + } + let baseline_state_change_seq = response["result"]["baseline_state_change_seq"] + .as_u64() + .unwrap_or(0); + let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { + return super::print_response(&cli_agent_error( + "cli:agent:prompt", + "agent_prompt_failed", + "agent prompt response did not include terminal_id", + )); + }; + let waited = match wait_for_named_agent( + terminal_id, + target, + timeout_ms.map(Duration::from_millis), + AgentWaitMode::AfterPrompt { + baseline_state_change_seq, + }, + ) { + Ok(waited) => waited, + Err(err) => { + return print_agent_transport_error( + err, + "cli:agent:prompt", + "agent_prompt_transport_failed", + ) + } + }; + match waited { + Ok(agent) => { + response["result"]["agent"] = agent; + super::print_response(&response) + } + Err(error) => super::print_response(&error), + } +} + fn agent_send(args: &[String]) -> std::io::Result { if args.len() < 2 { eprintln!("usage: herdr agent send "); @@ -640,42 +877,20 @@ fn agent_read(args: &[String]) -> std::io::Result { })?) } -fn agent_wait_status_satisfied(desired: AgentStatus, current: &str) -> bool { - match desired { - AgentStatus::Idle => matches!(current, "idle" | "done"), - AgentStatus::Working => current == "working", - AgentStatus::Blocked => current == "blocked", - AgentStatus::Unknown => current == "unknown", - AgentStatus::Done => false, - } -} - -fn parse_agent_wait_status(value: &str) -> std::io::Result { - match value { - "idle" => Ok(AgentStatus::Idle), - "working" => Ok(AgentStatus::Working), - "blocked" => Ok(AgentStatus::Blocked), - "unknown" => Ok(AgentStatus::Unknown), - "done" => Err(std::io::Error::other( - "done is a UI attention state; use idle for CLI agent completion waits", - )), - _ => Err(std::io::Error::other(format!( - "invalid agent status: {value} (expected idle, working, blocked, or unknown)" - ))), - } -} - fn print_agent_help() { eprintln!("herdr agent commands:"); eprintln!(" herdr agent list"); eprintln!(" herdr agent get "); eprintln!(" herdr agent read [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); eprintln!(" herdr agent send "); + eprintln!(" herdr agent prompt [--wait] [--timeout MS]"); eprintln!(" herdr agent rename |--clear"); eprintln!(" herdr agent focus "); - eprintln!(" herdr agent wait --status [--timeout MS]"); + eprintln!(" herdr agent wait [--timeout MS]"); eprintln!(" herdr agent attach [--takeover]"); - eprintln!(" herdr agent start [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- "); + eprintln!( + " herdr agent start --kind KIND --pane ID [--timeout MS] [-- ]" + ); eprintln!(" herdr agent explain [--json]"); eprintln!(" herdr agent explain --file PATH --agent LABEL [--json]"); eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids"); diff --git a/src/cli/spec.rs b/src/cli/spec.rs index f27eed49..31cd834f 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -284,6 +284,14 @@ fn agent_command() -> Command { .arg(required("target", "TARGET")) .arg(required("text", "TEXT")), ) + .subcommand( + Command::new("prompt") + .about("Submit a prompt to a named agent") + .arg(required("name", "NAME")) + .arg(required("text", "TEXT")) + .arg(flag("wait")) + .arg(option("timeout", "MS")), + ) .subcommand( Command::new("rename") .about("Rename an agent") @@ -294,9 +302,8 @@ fn agent_command() -> Command { .subcommand(id_command("focus", "target", "Focus an agent")) .subcommand( Command::new("wait") - .about("Wait for an agent status") - .arg(required("target", "TARGET")) - .arg(agent_wait_status_option()) + .about("Wait until a named agent is no longer working") + .arg(required("name", "NAME")) .arg(option("timeout", "MS")), ) .subcommand( @@ -307,15 +314,17 @@ fn agent_command() -> Command { ) .subcommand( Command::new("start") - .about("Start an agent command") + .about("Start a supported interactive agent in an existing pane") .arg(required("name", "NAME")) - .arg(path_option("cwd", "PATH")) - .arg(option("workspace", "ID")) - .arg(option("tab", "ID")) - .arg(split_option()) - .arg(env_option()) - .arg(flag("focus")) - .arg(flag("no-focus")), + .arg(option("kind", "KIND").required(true)) + .arg(option("pane", "ID").required(true)) + .arg(option("timeout", "MS")) + .arg( + Arg::new("agent_args") + .value_name("AGENT_ARG") + .num_args(0..) + .last(true), + ), ) .subcommand( Command::new("explain") @@ -754,10 +763,6 @@ fn direction_option() -> Arg { option("direction", "DIRECTION").value_parser(["left", "right", "up", "down"]) } -fn split_option() -> Arg { - option("split", "DIRECTION").value_parser(["right", "down"]) -} - fn split_direction_option() -> Arg { option("direction", "DIRECTION").value_parser(["right", "down"]) } @@ -768,12 +773,6 @@ fn status_option(name: &'static str, required: bool) -> Arg { .value_parser(["idle", "working", "blocked", "done", "unknown"]) } -fn agent_wait_status_option() -> Arg { - option("status", "STATUS") - .required(true) - .value_parser(["idle", "working", "blocked", "unknown"]) -} - fn pane_agent_state_option(name: &'static str) -> Arg { option(name, "STATUS") .required(true) @@ -927,14 +926,11 @@ mod tests { } #[test] - fn spec_includes_agent_status_values() { + fn spec_keeps_agent_wait_status_free() { let cmd = super::command(); let wait = command_path(&cmd, &["agent", "wait"]); - let values = option_values(wait, "status"); - assert!(values.contains(&"idle".to_string())); - assert!(values.contains(&"working".to_string())); - assert!(values.contains(&"blocked".to_string())); - assert!(!values.contains(&"done".to_string())); + assert!(!has_option(wait, "status")); + assert!(has_option(wait, "timeout")); } #[test] @@ -954,12 +950,17 @@ mod tests { } #[test] - fn spec_does_not_complete_agent_start_argv_without_separator() { + fn spec_models_agent_start_target_and_trailing_args() { let cmd = super::command(); let agent_start = command_path(&cmd, &["agent", "start"]); - assert!(!agent_start + assert!(has_option(agent_start, "kind")); + assert!(has_option(agent_start, "pane")); + for legacy in ["cwd", "workspace", "tab", "split", "focus", "env", "argv"] { + assert!(!has_option(agent_start, legacy), "legacy option --{legacy}"); + } + assert!(agent_start .get_arguments() - .any(|arg| arg.get_id() == "argv")); + .any(|arg| arg.get_id() == "agent_args")); } #[test] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 5c220b52..53c3213a 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -65,6 +65,31 @@ pub enum Agent { } impl Agent { + #[cfg(test)] + pub const ALL: [Self; 21] = [ + Self::Pi, + Self::Claude, + Self::Codex, + Self::Gemini, + Self::Cursor, + Self::Devin, + Self::Antigravity, + Self::Cline, + Self::Omp, + Self::Mastracode, + Self::OpenCode, + Self::GithubCopilot, + Self::Kimi, + Self::Kiro, + Self::Droid, + Self::Amp, + Self::Grok, + Self::Hermes, + Self::Kilo, + Self::Qodercli, + Self::Maki, + ]; + pub const SCREEN_MANIFEST_AGENTS: [Self; 19] = [ Self::Pi, Self::Claude, @@ -114,6 +139,32 @@ pub fn agent_label(agent: Agent) -> &'static str { } } +pub fn interactive_agent_executable(agent: Agent) -> &'static str { + match agent { + Agent::Pi => "pi", + Agent::Claude => "claude", + Agent::Codex => "codex", + Agent::Gemini => "gemini", + Agent::Cursor => "cursor-agent", + Agent::Devin => "devin", + Agent::Antigravity => "agy", + Agent::Cline => "cline", + Agent::Omp => "omp", + Agent::Mastracode => "mastracode", + Agent::OpenCode => "opencode", + Agent::GithubCopilot => "copilot", + Agent::Kimi => "kimi", + Agent::Kiro => "kiro-cli", + Agent::Droid => "droid", + Agent::Amp => "amp", + Agent::Grok => "grok", + Agent::Hermes => "hermes", + Agent::Kilo => "kilo", + Agent::Qodercli => "qodercli", + Agent::Maki => "maki", + } +} + pub fn parse_agent_label(agent: &str) -> Option { let name = normalized_agent_lookup_name(agent); parse_canonical_agent_label(&name).or_else(|| lookup_agent(&name)) @@ -662,37 +713,44 @@ mod tests { #[test] fn every_agent_label_round_trips_through_canonical_and_alias_parsers() { - let agents = [ - Agent::Pi, - Agent::Claude, - Agent::Codex, - Agent::Gemini, - Agent::Cursor, - Agent::Devin, - Agent::Antigravity, - Agent::Cline, - Agent::Omp, - Agent::Mastracode, - Agent::OpenCode, - Agent::GithubCopilot, - Agent::Kimi, - Agent::Kiro, - Agent::Droid, - Agent::Amp, - Agent::Grok, - Agent::Hermes, - Agent::Kilo, - Agent::Qodercli, - Agent::Maki, - ]; - - for agent in agents { + for agent in Agent::ALL { let label = agent_label(agent); assert_eq!(parse_canonical_agent_label(label), Some(agent)); assert_eq!(parse_agent_label(label), Some(agent)); } } + #[test] + fn every_agent_has_a_canonical_interactive_executable() { + let expected = [ + (Agent::Pi, "pi"), + (Agent::Claude, "claude"), + (Agent::Codex, "codex"), + (Agent::Gemini, "gemini"), + (Agent::Cursor, "cursor-agent"), + (Agent::Devin, "devin"), + (Agent::Antigravity, "agy"), + (Agent::Cline, "cline"), + (Agent::Omp, "omp"), + (Agent::Mastracode, "mastracode"), + (Agent::OpenCode, "opencode"), + (Agent::GithubCopilot, "copilot"), + (Agent::Kimi, "kimi"), + (Agent::Kiro, "kiro-cli"), + (Agent::Droid, "droid"), + (Agent::Amp, "amp"), + (Agent::Grok, "grok"), + (Agent::Hermes, "hermes"), + (Agent::Kilo, "kilo"), + (Agent::Qodercli, "qodercli"), + (Agent::Maki, "maki"), + ]; + assert_eq!(expected.len(), Agent::ALL.len()); + for (agent, executable) in expected { + assert_eq!(interactive_agent_executable(agent), executable); + } + } + #[test] fn canonical_agent_labels_are_strict() { assert_eq!(parse_canonical_agent_label("claude-code"), None); diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs index e5121a88..29ba85f4 100644 --- a/src/handoff_runtime.rs +++ b/src/handoff_runtime.rs @@ -1,6 +1,13 @@ #[cfg(unix)] use serde::{Deserialize, Serialize}; +/// Long-lived pane runtime transferred during server replacement. +/// +/// Handoff preserves server-owned session state such as PTYs, processes, agent +/// identity, and durable plugin/session metadata. It intentionally does not +/// preserve transient coordination such as in-flight requests, waits, +/// subscriptions, client sockets, or pane-to-pane messages; clients reconnect +/// and retry those operations after replacement. #[cfg(unix)] #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct HandoffRuntimeState { diff --git a/src/persist/restore.rs b/src/persist/restore.rs index bd4d8382..2197e2bb 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -487,6 +487,9 @@ fn restore_tab( let saved_label = saved_pane.and_then(|p| p.label.clone()); let saved_agent_name = saved_pane.and_then(|p| p.agent_name.clone()); + let saved_managed_agent = saved_pane + .and_then(|pane| pane.managed_agent_kind.as_deref()) + .and_then(crate::detect::parse_canonical_agent_label); let saved_launch_argv = saved_pane.and_then(|p| p.launch_argv.clone()); let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); let saved_history = @@ -530,8 +533,12 @@ fn restore_tab( if let Some(label) = saved_label { terminal.set_manual_label(label); } - if let Some(agent_name) = saved_agent_name { - terminal.set_agent_name(agent_name); + match (saved_agent_name, saved_managed_agent) { + (Some(agent_name), Some(agent)) => { + terminal.restore_managed_agent(agent_name, agent) + } + (Some(agent_name), None) => terminal.set_agent_name(agent_name), + (None, _) => {} } if let Some(agent) = initial_restore_agent { let _ = terminal.set_detected_state_with_screen_signals_at( @@ -623,8 +630,13 @@ fn restore_tab( if let Some(label) = saved_label { terminal.set_manual_label(label); } - if let Some(agent_name) = saved_agent_name { - terminal.set_agent_name(agent_name); + match (saved_agent_name, saved_managed_agent) { + (Some(agent_name), Some(agent)) if was_imported => { + terminal.restore_managed_agent(agent_name, agent) + } + (Some(_), Some(_)) => {} + (Some(agent_name), None) => terminal.set_agent_name(agent_name), + (None, _) => {} } if let Some(agent) = initial_restore_agent { let _ = terminal.set_detected_state_with_screen_signals_at( @@ -1171,8 +1183,9 @@ mod tests { 0, super::super::snapshot::PaneSnapshot { cwd, - label: None, - agent_name: None, + label: Some("reviewer".into()), + agent_name: Some("reviewer".into()), + managed_agent_kind: Some("opencode".into()), agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { source: "herdr:opencode".into(), agent: "opencode".into(), @@ -1218,6 +1231,8 @@ mod tests { !terminal.respawn_shell_on_exit, "agent sessions should not use native restore lifecycle when resume_agents_on_restore is disabled" ); + assert_eq!(terminal.agent_name, None); + assert_eq!(terminal.manual_label.as_deref(), Some("reviewer")); let session = terminal .persisted_agent_session .as_ref() @@ -1256,6 +1271,7 @@ mod tests { cwd: cwd.clone(), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1266,6 +1282,7 @@ mod tests { cwd: cwd.clone(), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1318,6 +1335,7 @@ mod tests { cwd: cwd.clone(), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1327,6 +1345,7 @@ mod tests { cwd: cwd.clone(), label: Some("planner".into()), agent_name: Some("planner".into()), + managed_agent_kind: None, agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { source: "herdr:codex".into(), agent: "codex".into(), @@ -1416,6 +1435,8 @@ mod tests { assert_eq!(workspace.tabs[3].number, 5); assert_eq!(detail.tab_idx, 3); assert_eq!(detail.agent_label, "planner"); + let terminal_id = &workspace.tabs[3].panes[&agent_pane].attached_terminal_id; + assert_eq!(terminals[terminal_id].managed_agent_kind(), None); } #[test] @@ -1478,6 +1499,7 @@ mod tests { cwd, label: None, agent_name: None, + managed_agent_kind: None, agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot { source: "herdr:codex".into(), agent: "codex".into(), @@ -1643,6 +1665,7 @@ mod tests { cwd: cwd.clone(), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index f78fd836..e1e112a0 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -102,6 +102,8 @@ pub struct PaneSnapshot { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub managed_agent_kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub launch_argv: Option>, @@ -319,51 +321,51 @@ fn capture_tab( let cwd = tab .cwd_for_pane(*id, terminals, terminal_runtimes) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - let label = tab - .panes - .get(id) - .and_then(|pane| terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.manual_label.clone()); - let agent_name = tab + let terminal = tab .panes .get(id) - .and_then(|pane| terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.agent_name.clone()); - let launch_argv = tab - .panes - .get(id) - .and_then(|pane| terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| terminal.launch_argv.clone()); - let agent_session = - tab.panes - .get(id) - .and_then(|pane| terminals.get(&pane.attached_terminal_id)) - .and_then(|terminal| { - if let Some(authority) = terminal.hook_authority.as_ref() { - if let Some(session_ref) = authority.session_ref.as_ref() { - return Some(PaneAgentSessionSnapshot { - source: authority.source.clone(), - agent: authority.agent_label.clone(), - kind: session_ref.kind, - value: session_ref.value.clone(), - }); - } - } - terminal.persisted_agent_session.as_ref().map(|session| { - PaneAgentSessionSnapshot { - source: session.source.clone(), - agent: session.agent.clone(), - kind: session.session_ref.kind, - value: session.session_ref.value.clone(), - } - }) - }); + .and_then(|pane| terminals.get(&pane.attached_terminal_id)); + let label = terminal.and_then(|terminal| terminal.manual_label.clone()); + let (agent_name, managed_agent_kind) = terminal + .filter(|terminal| !terminal.managed_agent_launch_pending()) + .map(|terminal| { + ( + terminal.agent_name.clone(), + terminal + .managed_agent_kind() + .map(|agent| crate::detect::agent_label(agent).to_string()), + ) + }) + .unwrap_or_default(); + let launch_argv = terminal.and_then(|terminal| terminal.launch_argv.clone()); + let agent_session = terminal.and_then(|terminal| { + if let Some(authority) = terminal.hook_authority.as_ref() { + if let Some(session_ref) = authority.session_ref.as_ref() { + return Some(PaneAgentSessionSnapshot { + source: authority.source.clone(), + agent: authority.agent_label.clone(), + kind: session_ref.kind, + value: session_ref.value.clone(), + }); + } + } + terminal + .persisted_agent_session + .as_ref() + .map(|session| PaneAgentSessionSnapshot { + source: session.source.clone(), + agent: session.agent.clone(), + kind: session.session_ref.kind, + value: session.session_ref.value.clone(), + }) + }); panes.insert( id.raw(), PaneSnapshot { cwd, label, agent_name, + managed_agent_kind, agent_session, launch_argv, }, @@ -556,6 +558,43 @@ mod tests { } } + #[test] + fn managed_agent_snapshot_omits_pending_and_persists_active_ownership() { + let mut state = state_with_workspaces(&["managed-snapshot"]); + let root = state.workspaces[0].tabs[0].root_pane; + let terminal_id = state.workspaces[0].tabs[0].panes[&root] + .attached_terminal_id + .clone(); + let now = std::time::Instant::now(); + state + .terminals + .get_mut(&terminal_id) + .unwrap() + .begin_managed_agent( + "reviewer".into(), + crate::detect::Agent::Pi, + now, + std::time::Duration::ZERO, + std::time::Duration::from_secs(1), + ); + + let pending = capture_from_state(&state); + let pending_pane = &pending.workspaces[0].tabs[0].panes[&root.raw()]; + assert_eq!(pending_pane.agent_name, None); + assert_eq!(pending_pane.managed_agent_kind, None); + + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + assert!(terminal.reconcile_managed_agent_at(now, false)); + let active = capture_from_state(&state); + let active_pane = &active.workspaces[0].tabs[0].panes[&root.raw()]; + assert_eq!(active_pane.agent_name.as_deref(), Some("reviewer")); + assert_eq!(active_pane.managed_agent_kind.as_deref(), Some("pi")); + } + #[test] fn round_trip_empty_session() { let snap = SessionSnapshot { @@ -606,6 +645,7 @@ mod tests { cwd: PathBuf::from("/home/can/Projects/herdr"), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -616,6 +656,7 @@ mod tests { cwd: PathBuf::from("/home/can/Projects/website"), label: Some("website".into()), agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1157,6 +1198,7 @@ mod tests { cwd: PathBuf::from("/tmp/this-directory-does-not-exist-for-herdr-test"), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, @@ -1169,6 +1211,7 @@ mod tests { .unwrap_or_else(|_| PathBuf::from("/tmp")), label: None, agent_name: None, + managed_agent_kind: None, agent_session: None, launch_argv: None, }, diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 6b5700cc..30bea5eb 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -27,6 +27,10 @@ pub(crate) fn pane_custom_command_pty_builder_platform( portable_pty::CommandBuilder::from_argv(raw_command_argv(command, "-c")) } +pub(crate) fn interactive_shell_command(_argv: &[String], _shell_name: &str) -> Option { + None +} + /// Unsupported platform stub. pub(crate) fn scrollback_editor_argv(_path: &std::path::Path) -> std::io::Result> { Err(std::io::Error::new( @@ -43,6 +47,10 @@ pub fn current_process_is_detached_server_daemon() -> bool { false } +pub(crate) fn available_pane_shell(_child_pid: u32) -> Option { + None +} + /// Unsupported platform stub. pub fn foreground_job(_child_pid: u32) -> Option { None diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 48f1ef1c..6557b41b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -70,6 +70,10 @@ pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result< Ok(vec!["/bin/sh".to_string(), "-c".to_string(), command]) } +pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { + super::interactive_unix_shell_command(argv, shell_name, shell_quote) +} + fn shell_quote(value: &str) -> String { if !value.is_empty() && value.chars().all(|ch| { @@ -87,6 +91,10 @@ fn shell_quote(value: &str) -> String { } /// Collect the foreground terminal job for a given child PID. +pub(crate) fn available_pane_shell(child_pid: u32) -> Option { + super::available_pane_shell_from_job(child_pid, foreground_job(child_pid)?) +} + pub fn foreground_job(child_pid: u32) -> Option { let tpgid = foreground_process_group_id(child_pid)?; let members = foreground_process_group_members(child_pid, tpgid)?; diff --git a/src/platform/macos.rs b/src/platform/macos.rs index f35fd1eb..161354d7 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -45,6 +45,10 @@ pub(crate) fn scrollback_editor_argv(path: &Path) -> std::io::Result Ok(vec!["/bin/sh".to_string(), "-c".to_string(), command]) } +pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { + super::interactive_unix_shell_command(argv, shell_name, shell_quote) +} + fn shell_quote(value: &str) -> String { if !value.is_empty() && value.chars().all(|ch| { @@ -315,6 +319,10 @@ fn target_nofile_soft_limit( (current < target).then_some(target) } +pub(crate) fn available_pane_shell(child_pid: u32) -> Option { + super::available_pane_shell_from_job(child_pid, foreground_job(child_pid)?) +} + /// Collect the foreground terminal job for a given child PID. pub fn foreground_job(child_pid: u32) -> Option { if child_pid == 0 { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 82a6704d..1415181e 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -157,6 +157,90 @@ mod fallback; #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] pub use fallback::*; +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn available_pane_shell_from_job(child_pid: u32, job: ForegroundJob) -> Option { + if job.process_group_id != child_pid + || job.processes.iter().any(|process| process.pid != child_pid) + { + return None; + } + job.processes + .into_iter() + .find(|process| process.pid == child_pid) + .map(|process| process.name) + .filter(|name| is_pane_shell_process_name(name)) +} + +fn normalized_process_name(name: &str) -> String { + name.rsplit(['/', '\\']) + .next() + .unwrap_or(name) + .trim_start_matches('-') + .trim_end_matches(".exe") + .to_ascii_lowercase() +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn is_powershell_process_name(name: &str) -> bool { + matches!( + normalized_process_name(name).as_str(), + "pwsh" | "powershell" + ) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn interactive_unix_shell_command( + argv: &[String], + shell_name: &str, + quote_posix_arg: fn(&str) -> String, +) -> Option { + let quote = if is_powershell_process_name(shell_name) { + quote_powershell_arg + } else { + quote_posix_arg + }; + let mut parts = argv.iter(); + let mut command = quote(parts.next()?); + for part in parts { + command.push(' '); + command.push_str("e(part)); + } + Some(command) +} + +pub(crate) fn quote_powershell_arg(value: &str) -> String { + if !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'_' | b'-' | b'.' | b'/' | b':' | b'+' | b'=') + }) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "''")) +} + +pub(crate) fn is_pane_shell_process_name(name: &str) -> bool { + let normalized = normalized_process_name(name); + matches!( + normalized.as_str(), + "sh" | "bash" + | "dash" + | "zsh" + | "fish" + | "ksh" + | "mksh" + | "csh" + | "tcsh" + | "elvish" + | "xonsh" + | "nu" + | "pwsh" + | "powershell" + | "cmd" + ) +} + #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn process_agent_hint(_pid: u32) -> Option { None @@ -226,6 +310,16 @@ impl PrefixInputSource for RealPrefixInputSource { mod tests { use super::*; + #[test] + fn pane_shell_process_names_reject_exec_replacement_programs() { + for shell in ["bash", "-zsh", "/bin/fish", "pwsh", "powershell.exe"] { + assert!(is_pane_shell_process_name(shell), "{shell}"); + } + for program in ["vim", "nvim", "cargo", "test-runner", "opencode"] { + assert!(!is_pane_shell_process_name(program), "{program}"); + } + } + #[test] fn detached_custom_command_preserves_unix_login_shell_flag() { let cmd = detached_custom_command_process("echo hello"); @@ -269,6 +363,28 @@ mod tests { assert_eq!(parse_agent_env_hint(b"HERDR_AGENT=not-an-agent\0"), None); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn interactive_shell_command_quotes_for_posix_and_powershell() { + let argv = vec![ + "pi".into(), + String::new(), + "two words".into(), + "a'b".into(), + "$HOME".into(), + "semi;colon".into(), + "@options".into(), + ]; + assert_eq!( + interactive_shell_command(&argv, "bash").as_deref(), + Some("pi '' 'two words' 'a'\\''b' '$HOME' 'semi;colon' @options") + ); + assert_eq!( + interactive_shell_command(&argv, "pwsh").as_deref(), + Some("pi '' 'two words' 'a''b' '$HOME' 'semi;colon' '@options'") + ); + } + #[test] fn read_limited_reader_returns_complete_data_under_limit() { let input = std::io::Cursor::new(b"image".to_vec()); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 24de21ad..ef41e662 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -62,6 +62,71 @@ fn raw_command_shell(comspec: Option) -> std::ffi::OsString .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()) } +pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { + let shell_name = shell_name.to_ascii_lowercase(); + let powershell = shell_name.contains("powershell") || shell_name.contains("pwsh"); + let script = powershell_agent_script(argv)?; + if powershell { + Some(script) + } else { + Some(cmd_encoded_powershell_command(&script)) + } +} + +fn powershell_agent_script(argv: &[String]) -> Option { + let (program, args) = argv.split_first()?; + let command_line = args + .iter() + .map(|arg| quote_windows_command_line_arg(arg)) + .collect::>() + .join(" "); + Some(format!( + "$p=Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -Wait -PassThru", + super::quote_powershell_arg(program), + super::quote_powershell_arg(&command_line), + )) +} + +fn quote_windows_command_line_arg(value: &str) -> String { + if !value.is_empty() + && !value + .chars() + .any(|ch| matches!(ch, ' ' | '\t' | '\n' | '\x0b' | '"')) + { + return value.to_string(); + } + + let mut quoted = String::from("\""); + let mut backslashes = 0; + for ch in value.chars() { + if ch == '\\' { + backslashes += 1; + continue; + } + if ch == '"' { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + } else { + quoted.push_str(&"\\".repeat(backslashes)); + } + backslashes = 0; + quoted.push(ch); + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} + +fn cmd_encoded_powershell_command(script: &str) -> String { + use base64::Engine as _; + + let utf16 = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + let encoded = base64::engine::general_purpose::STANDARD.encode(utf16); + format!("powershell.exe -NoLogo -NoProfile -EncodedCommand {encoded}") +} + pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec")) } @@ -155,6 +220,23 @@ pub fn foreground_job(child_pid: u32) -> Option { select_pane_foreground_job(child_pid, &entries) } +pub(crate) fn available_pane_shell(child_pid: u32) -> Option { + available_pane_shell_from_snapshot(child_pid, &snapshot_processes()) +} + +fn available_pane_shell_from_snapshot( + child_pid: u32, + entries: &[WindowsProcessEntry], +) -> Option { + let shell = entries.iter().find(|entry| entry.pid == child_pid)?; + if !super::is_pane_shell_process_name(&shell.name) { + return None; + } + descendant_entries(child_pid, entries) + .is_empty() + .then(|| shell.name.clone()) +} + pub fn foreground_group_leader_job(process_group_id: u32) -> Option { let entries = snapshot_processes(); let entry = entries.iter().find(|entry| entry.pid == process_group_id)?; @@ -670,6 +752,91 @@ mod tests { AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow, }; + #[test] + fn cmd_agent_command_encodes_edge_arguments_without_cmd_expansion() { + use base64::Engine as _; + + assert_eq!(super::super::quote_powershell_arg("@options"), "'@options'"); + let argv = vec![ + "pi".into(), + String::new(), + "two words".into(), + "100%".into(), + "wow!".into(), + "a'b".into(), + ]; + let command = super::interactive_shell_command(&argv, "cmd.exe").unwrap(); + let encoded = command.split_whitespace().last().unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + let utf16 = bytes + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect::>(); + assert_eq!( + String::from_utf16(&utf16).unwrap(), + "$p=Start-Process -FilePath pi -ArgumentList '\"\" \"two words\" 100% wow! a''b' -NoNewWindow -Wait -PassThru" + ); + } + + #[test] + fn windows_shells_round_trip_agent_arguments_through_a_real_command() { + let base = std::env::temp_dir().join(format!( + "herdr-agent-argv-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() + )); + fs::create_dir_all(&base).unwrap(); + let helper = base.join("pi.cmd"); + fs::write( + &helper, + "@echo off\r\n>\"%HERDR_ARGV_CAPTURE%\" (\r\necho(%~1\r\necho(%~2\r\necho(%~3\r\necho(%~4\r\necho(%~5\r\necho(%~6\r\n)\r\n", + ) + .unwrap(); + let argv = vec![ + "pi".into(), + String::new(), + "two words".into(), + "100%".into(), + "wow!".into(), + "a'b".into(), + "@options".into(), + ]; + let inherited_path = std::env::var_os("PATH").unwrap_or_default(); + let path = format!("{};{}", base.display(), inherited_path.to_string_lossy()); + + for shell in ["powershell.exe", "cmd.exe"] { + let capture = base.join(format!("{shell}.txt")); + let command = super::interactive_shell_command(&argv, shell).unwrap(); + let status = if shell == "cmd.exe" { + Command::new("cmd.exe") + .args(["/d", "/c", &command]) + .env("PATH", &path) + .env("HERDR_ARGV_CAPTURE", &capture) + .status() + .unwrap() + } else { + Command::new("powershell.exe") + .args(["-NoLogo", "-NoProfile", "-Command", &command]) + .env("PATH", &path) + .env("HERDR_ARGV_CAPTURE", &capture) + .status() + .unwrap() + }; + assert!(status.success(), "{shell} command failed"); + assert_eq!( + fs::read_to_string(capture).unwrap().replace("\r\n", "\n"), + "\ntwo words\n100%\nwow!\na'b\n@options\n" + ); + } + + let _ = fs::remove_dir_all(base); + } + const CONSOLE_TEST_CHILD_ENV: &str = "HERDR_TEST_CONSOLE_CHILD_MODE"; const CONSOLE_TEST_PARENT_PID_ENV: &str = "HERDR_TEST_CONSOLE_PARENT_PID"; @@ -1001,6 +1168,27 @@ mod tests { assert_eq!(job.processes[0].name, "powershell.exe"); } + #[test] + fn windows_shell_is_available_only_without_descendants() { + let shell_only = vec![test_entry(10, 1, "powershell.exe", &["powershell.exe"])]; + assert_eq!( + super::available_pane_shell_from_snapshot(10, &shell_only).as_deref(), + Some("powershell.exe") + ); + + let busy = vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "git.exe", &["git.exe", "status"]), + ]; + assert_eq!(super::available_pane_shell_from_snapshot(10, &busy), None); + + let replaced = vec![test_entry(10, 1, "vim.exe", &["vim.exe"])]; + assert_eq!( + super::available_pane_shell_from_snapshot(10, &replaced), + None + ); + } + #[test] fn windows_process_tree_returns_shell_for_multiple_agent_descendants() { let entries = vec![ diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index e31f8ff5..9cc2924b 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 16; +pub const PROTOCOL_VERSION: u32 = 17; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 632558fd..815a8b80 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1,8 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; -#[cfg(any(windows, test))] -use std::time::Duration; -use std::time::Instant; +use std::time::{Duration, Instant}; // Effective state arbitration is intentionally centralized here. Full lifecycle // Herdr hook integrations are hook-authoritative while live; screen recovery @@ -46,6 +44,22 @@ struct StaleFullLifecycleHookSession { session_ref: crate::agent_resume::AgentSessionRef, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ManagedAgentPhase { + Pending { + ready_after: Option, + deadline: Instant, + }, + Active, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ManagedAgent { + kind: Agent, + observed_expected: bool, + phase: ManagedAgentPhase, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectiveStateChange { pub previous_agent_label: Option, @@ -89,6 +103,7 @@ pub struct TerminalState { pub terminal_title: Option, pub manual_label: Option, pub agent_name: Option, + managed_agent: Option, hook_report_sequences: HashMap, suppressed_full_lifecycle_hook_reports: HashMap, stale_full_lifecycle_hook_sessions: HashMap>, @@ -119,6 +134,7 @@ impl TerminalState { terminal_title: None, manual_label: None, agent_name: None, + managed_agent: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), stale_full_lifecycle_hook_sessions: HashMap::new(), @@ -1307,8 +1323,125 @@ impl TerminalState { self.agent_name = (!name.is_empty()).then_some(name); } + pub fn begin_managed_agent( + &mut self, + name: String, + kind: Agent, + now: Instant, + settle_delay: Duration, + timeout: Duration, + ) { + self.set_agent_name(name); + self.managed_agent = Some(ManagedAgent { + kind, + observed_expected: false, + phase: ManagedAgentPhase::Pending { + ready_after: Some(now.checked_add(settle_delay).unwrap_or(now)), + deadline: now.checked_add(timeout).unwrap_or(now), + }, + }); + } + + pub fn managed_agent_launch_pending(&self) -> bool { + self.managed_agent + .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Pending { .. })) + } + + pub fn managed_agent_interactive_ready(&self) -> bool { + self.managed_agent.is_some_and(|managed| { + matches!(managed.phase, ManagedAgentPhase::Active) + && self.effective_known_agent() == Some(managed.kind) + && matches!(self.state, AgentState::Idle | AgentState::Blocked) + }) + } + + pub fn managed_agent_kind(&self) -> Option { + self.managed_agent.map(|managed| managed.kind) + } + + pub fn next_managed_agent_deadline(&self) -> Option { + let ManagedAgentPhase::Pending { + ready_after, + deadline, + } = self.managed_agent?.phase + else { + return None; + }; + Some(ready_after.unwrap_or(deadline).min(deadline)) + } + + pub fn reconcile_managed_agent_at(&mut self, now: Instant, process_exited: bool) -> bool { + let Some(managed) = self.managed_agent else { + return false; + }; + let known_agent = self.effective_known_agent(); + let observed_expected = managed.observed_expected || known_agent == Some(managed.kind); + let clear = process_exited + || known_agent.is_some_and(|agent| agent != managed.kind) + || matches!(managed.phase, ManagedAgentPhase::Pending { .. }) + && observed_expected + && known_agent.is_none(); + if clear { + self.managed_agent = None; + self.agent_name = None; + return true; + } + if let ManagedAgentPhase::Pending { + ready_after, + deadline, + } = managed.phase + { + if now >= deadline { + self.managed_agent = None; + self.agent_name = None; + return true; + } + if ready_after.is_none_or(|ready_after| now >= ready_after) { + if known_agent == Some(managed.kind) + && matches!(self.state, AgentState::Idle | AgentState::Blocked) + { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + observed_expected: true, + phase: ManagedAgentPhase::Active, + }); + return true; + } + if ready_after.is_some() { + self.managed_agent = Some(ManagedAgent { + kind: managed.kind, + observed_expected, + phase: ManagedAgentPhase::Pending { + ready_after: None, + deadline, + }, + }); + return true; + } + } + if observed_expected != managed.observed_expected { + self.managed_agent = Some(ManagedAgent { + observed_expected, + ..managed + }); + return true; + } + } + false + } + + pub fn restore_managed_agent(&mut self, name: String, kind: Agent) { + self.set_agent_name(name); + self.managed_agent = Some(ManagedAgent { + kind, + observed_expected: true, + phase: ManagedAgentPhase::Active, + }); + } + pub fn clear_agent_name(&mut self) { self.agent_name = None; + self.managed_agent = None; } pub fn clear_agent_runtime_identity_after_respawn(&mut self) { @@ -1413,6 +1546,62 @@ mod tests { .to_string() } + #[test] + fn managed_agent_activates_only_after_matching_settled_detection() { + let mut terminal = test_terminal(); + let now = Instant::now(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::Pi, + now, + Duration::from_millis(100), + Duration::from_secs(1), + ); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + + assert!(terminal.managed_agent_launch_pending()); + assert!(!terminal.managed_agent_interactive_ready()); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(100), false)); + assert!(!terminal.managed_agent_launch_pending()); + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + terminal.set_detected_state(None, AgentState::Unknown); + assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false)); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(102), true)); + assert_eq!(terminal.agent_name, None); + } + + #[test] + fn managed_agent_mismatch_and_timeout_release_name() { + let now = Instant::now(); + let mut mismatch = test_terminal(); + mismatch.begin_managed_agent( + "reviewer".into(), + Agent::Pi, + now, + Duration::ZERO, + Duration::from_secs(1), + ); + mismatch.set_detected_state(Some(Agent::Codex), AgentState::Idle); + assert!(mismatch.reconcile_managed_agent_at(now, false)); + assert_eq!(mismatch.agent_name, None); + assert_eq!(mismatch.managed_agent_kind(), None); + + let mut timed_out = test_terminal(); + timed_out.begin_managed_agent( + "reviewer".into(), + Agent::Pi, + now, + Duration::from_millis(10), + Duration::from_millis(20), + ); + assert!(timed_out.reconcile_managed_agent_at(now + Duration::from_millis(20), false)); + assert_eq!(timed_out.agent_name, None); + assert_eq!(timed_out.managed_agent_kind(), None); + } + #[test] fn stabilization_uses_raw_policy_state() { let detection = AgentDetection { -- cgit v1.2.3