diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 23:52:20 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-19 23:56:07 +0300 |
| commit | 3f80947678a9723434aa7fddfe23750b5393e24c (patch) | |
| tree | 6f463797132acfc51dfb6e135820a052954b4f13 /src | |
| parent | f76f895efe5b9f19aa9db909354c6c0f2195630b (diff) | |
feat: make agent automation a first-class cli primitive
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/api/schema.rs | 6 | ||||
| -rw-r--r-- | src/api/schema/agents.rs | 23 | ||||
| -rw-r--r-- | src/api/schema/events.rs | 4 | ||||
| -rw-r--r-- | src/api/schema/integrations.rs | 19 | ||||
| -rw-r--r-- | src/api/schema/response.rs | 1 | ||||
| -rw-r--r-- | src/api/schema/tests.rs | 26 | ||||
| -rw-r--r-- | src/api/server.rs | 110 | ||||
| -rw-r--r-- | src/api/wait.rs | 430 | ||||
| -rw-r--r-- | src/app/actions.rs | 75 | ||||
| -rw-r--r-- | src/app/agents.rs | 83 | ||||
| -rw-r--r-- | src/app/api.rs | 115 | ||||
| -rw-r--r-- | src/app/api/agents.rs | 177 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 33 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 52 | ||||
| -rw-r--r-- | src/app/ids.rs | 8 | ||||
| -rw-r--r-- | src/app/mod.rs | 64 | ||||
| -rw-r--r-- | src/app/terminal_targets.rs | 61 | ||||
| -rw-r--r-- | src/cli.rs | 258 | ||||
| -rw-r--r-- | src/cli/agent.rs | 384 | ||||
| -rw-r--r-- | src/cli/pane.rs | 112 | ||||
| -rw-r--r-- | src/cli/protocol_guard.rs | 4 | ||||
| -rw-r--r-- | src/cli/spec.rs | 458 | ||||
| -rw-r--r-- | src/detect/mod.rs | 1 | ||||
| -rw-r--r-- | src/main.rs | 10 | ||||
| -rw-r--r-- | src/persist/restore.rs | 42 | ||||
| -rw-r--r-- | src/terminal/metadata.rs | 2 | ||||
| -rw-r--r-- | src/terminal/state.rs | 341 |
28 files changed, 2089 insertions, 811 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index 618fa4f9..badf1707 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -45,6 +45,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::AgentFocus(_) | Method::AgentStart(_) | Method::AgentPrompt(_) + | Method::AgentSendKeys(_) | Method::PaneSplit(_) | Method::PaneSwap(_) | Method::PaneMove(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 41f372e3..efaee54c 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -109,8 +109,8 @@ pub enum Method { AgentRead(AgentReadParams), #[serde(rename = "agent.explain")] AgentExplain(AgentTarget), - #[serde(rename = "agent.send")] - AgentSend(AgentSendParams), + #[serde(rename = "agent.send_keys")] + AgentSendKeys(AgentSendKeysParams), #[serde(rename = "agent.rename")] AgentRename(AgentRenameParams), #[serde(rename = "agent.focus")] @@ -119,6 +119,8 @@ pub enum Method { AgentStart(AgentStartParams), #[serde(rename = "agent.prompt")] AgentPrompt(AgentPromptParams), + #[serde(rename = "agent.wait")] + AgentWait(AgentWaitParams), #[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 8592e56b..59949293 100644 --- a/src/api/schema/agents.rs +++ b/src/api/schema/agents.rs @@ -17,9 +17,26 @@ pub struct AgentReadParams { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct AgentSendParams { +pub struct AgentSendKeysParams { pub target: String, - pub text: String, + pub keys: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct AgentWaitParams { + pub target: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub until: Vec<AgentStatus>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct AgentPromptWaitOptions { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub until: Vec<AgentStatus>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option<u64>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -45,6 +62,8 @@ pub struct AgentStartParams { pub struct AgentPromptParams { pub target: String, pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wait: Option<AgentPromptWaitOptions>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs index 25ccc8ec..50b5d522 100644 --- a/src/api/schema/events.rs +++ b/src/api/schema/events.rs @@ -520,6 +520,10 @@ pub enum EventData { workspace_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] agent: Option<String>, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + released: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + final_status: Option<AgentStatus>, }, PaneAgentStatusChanged { pane_id: String, diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index f20b9095..5a267ff1 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -29,6 +29,25 @@ pub enum IntegrationTarget { Mastracode, } +impl IntegrationTarget { + pub(crate) const ALL: [Self; 14] = [ + Self::Pi, + Self::Omp, + Self::Claude, + Self::Codex, + Self::Copilot, + Self::Devin, + Self::Droid, + Self::Kimi, + Self::Opencode, + Self::Kilo, + Self::Hermes, + Self::Qodercli, + Self::Cursor, + Self::Mastracode, + ]; +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct IntegrationInstallResult { pub messages: Vec<String>, diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index ad058191..e1f47efd 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -103,7 +103,6 @@ pub enum ResponseResult { }, AgentPrompted { agent: AgentInfo, - baseline_state_change_seq: u64, }, AgentList { agents: Vec<AgentInfo>, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 232c2b73..236d4f12 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -86,6 +86,7 @@ fn agent_start_and_prompt_requests_round_trip() { method: Method::AgentPrompt(AgentPromptParams { target: "reviewer".into(), text: "review this".into(), + wait: None, }), }; let prompt_json = serde_json::to_value(&prompt).unwrap(); @@ -94,6 +95,31 @@ fn agent_start_and_prompt_requests_round_trip() { serde_json::from_value::<Request>(prompt_json).unwrap(), prompt ); + + let prompt_and_wait = Request { + id: "prompt-and-wait".into(), + method: Method::AgentPrompt(AgentPromptParams { + target: "reviewer".into(), + text: "review this".into(), + wait: Some(AgentPromptWaitOptions { + until: vec![AgentStatus::Idle, AgentStatus::Done], + timeout_ms: Some(120_000), + }), + }), + }; + let prompt_and_wait_json = serde_json::to_value(&prompt_and_wait).unwrap(); + assert_eq!( + prompt_and_wait_json["params"]["wait"]["until"], + serde_json::json!(["idle", "done"]) + ); + assert_eq!( + prompt_and_wait_json["params"]["wait"]["timeout_ms"], + 120_000 + ); + assert_eq!( + serde_json::from_value::<Request>(prompt_and_wait_json).unwrap(), + prompt_and_wait + ); } #[test] diff --git a/src/api/server.rs b/src/api/server.rs index ce538853..0a36e2fa 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -14,7 +14,7 @@ use crate::api::schema::{ ErrorBody, ErrorResponse, Method, Request, ResponseResult, ServerCapabilities, SuccessResponse, }; use crate::api::subscriptions::ActiveSubscription; -use crate::api::wait::{wait_for_event, wait_for_output}; +use crate::api::wait::{prompt_agent, wait_for_agent, wait_for_event, wait_for_output}; use crate::api::{request_changes_ui, socket_path, ApiRequestMessage, ApiRequestSender, EventHub}; use crate::ipc::{ bind_local_listener, is_connection_closed_error, local_stream_peer_closed, @@ -218,62 +218,42 @@ fn handle_connection( result } Method::EventsWait(params) => { - let Some(response) = wait_for_event( + let response = wait_for_event( request_id.clone(), params, &mut stream, api_tx, event_hub, running, - )? - else { - crate::logging::api_request_completed( - &request_id, - method, - "client_disconnected", - changes_ui, - ); - return Ok(()); - }; - let result = write_text_line_allow_disconnect(&mut stream, &response); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - api_response_outcome(&response), - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result + )?; + finish_wait_response(&mut stream, response, &request_id, method, changes_ui) + } + Method::AgentPrompt(params) => { + let response = prompt_agent( + request_id.clone(), + params, + &mut stream, + api_tx, + event_hub, + running, + )?; + finish_wait_response(&mut stream, response, &request_id, method, changes_ui) + } + Method::AgentWait(params) => { + let response = wait_for_agent( + request_id.clone(), + params, + &mut stream, + api_tx, + event_hub, + running, + )?; + finish_wait_response(&mut stream, response, &request_id, method, changes_ui) } Method::PaneWaitForOutput(params) => { - let Some(response) = - wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)? - else { - crate::logging::api_request_completed( - &request_id, - method, - "client_disconnected", - changes_ui, - ); - return Ok(()); - }; - let result = write_text_line_allow_disconnect(&mut stream, &response); - match &result { - Ok(()) => crate::logging::api_request_completed( - &request_id, - method, - api_response_outcome(&response), - changes_ui, - ), - Err(err) => { - crate::logging::api_request_failed(&request_id, method, &err.to_string()) - } - } - result + let response = + wait_for_output(request_id.clone(), params, &mut stream, api_tx, running)?; + finish_wait_response(&mut stream, response, &request_id, method, changes_ui) } method_body => { let (response_write_tx, response_write_rx) = std::sync::mpsc::channel(); @@ -304,6 +284,35 @@ fn handle_connection( } } +fn finish_wait_response( + stream: &mut LocalStream, + response: Option<String>, + request_id: &str, + method: &'static str, + changes_ui: bool, +) -> std::io::Result<()> { + let Some(response) = response else { + crate::logging::api_request_completed( + request_id, + method, + "client_disconnected", + changes_ui, + ); + return Ok(()); + }; + let result = write_text_line_allow_disconnect(stream, &response); + match &result { + Ok(()) => crate::logging::api_request_completed( + request_id, + method, + api_response_outcome(&response), + changes_ui, + ), + Err(err) => crate::logging::api_request_failed(request_id, method, &err.to_string()), + } + result +} + fn handle_request( request: Request, api_tx: &ApiRequestSender, @@ -367,11 +376,12 @@ fn api_method_name(method: &Method) -> &'static str { Method::AgentGet(_) => "agent.get", Method::AgentRead(_) => "agent.read", Method::AgentExplain(_) => "agent.explain", - Method::AgentSend(_) => "agent.send", + Method::AgentSendKeys(_) => "agent.send_keys", Method::AgentRename(_) => "agent.rename", Method::AgentFocus(_) => "agent.focus", Method::AgentStart(_) => "agent.start", Method::AgentPrompt(_) => "agent.prompt", + Method::AgentWait(_) => "agent.wait", Method::PaneSplit(_) => "pane.split", Method::PaneSwap(_) => "pane.swap", Method::PaneMove(_) => "pane.move", diff --git a/src/api/wait.rs b/src/api/wait.rs index d556fa96..2f8da2e3 100644 --- a/src/api/wait.rs +++ b/src/api/wait.rs @@ -126,6 +126,404 @@ pub(super) fn wait_for_output( } } +pub(super) fn wait_for_agent( + request_id: String, + params: crate::api::schema::AgentWaitParams, + stream: &mut LocalStream, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc<AtomicBool>, +) -> std::io::Result<Option<String>> { + let last_event_sequence = event_hub.current_sequence(); + let initial = match agent_get(&request_id, ¶ms.target, api_tx) { + Ok(agent) => agent, + Err(response) => { + return serde_json::to_string(&response) + .map(Some) + .map_err(std::io::Error::other); + } + }; + let until = agent_wait_statuses(params.until); + if agent_wait_matches(&initial, &until, None) { + return agent_wait_success(request_id, initial).map(Some); + } + + match wait_for_resolved_agent( + request_id.clone(), + ResolvedAgentWait { + target: params.target, + until, + timeout_ms: params.timeout_ms, + initial, + last_event_sequence, + after_state_change_seq: None, + accept_transient_status: true, + }, + stream, + api_tx, + event_hub, + running, + )? { + Some(AgentWaitOutcome::Matched(agent)) => agent_wait_success(request_id, *agent).map(Some), + Some(AgentWaitOutcome::Response(response)) => Ok(Some(response)), + None => Ok(None), + } +} + +pub(super) fn prompt_agent( + request_id: String, + params: crate::api::schema::AgentPromptParams, + stream: &mut LocalStream, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc<AtomicBool>, +) -> std::io::Result<Option<String>> { + let Some(wait) = params.wait.clone() else { + return Ok(Some(dispatch_to_app_with_timeout( + Request { + id: request_id, + method: Method::AgentPrompt(params), + }, + api_tx, + None, + ))); + }; + + let last_event_sequence = event_hub.current_sequence(); + let before_prompt = match agent_get(&request_id, ¶ms.target, api_tx) { + Ok(agent) => agent, + Err(response) => { + return serde_json::to_string(&response) + .map(Some) + .map_err(std::io::Error::other); + } + }; + let target = params.target.clone(); + let prompt_response = dispatch_to_app_with_timeout( + Request { + id: request_id.clone(), + method: Method::AgentPrompt(params), + }, + api_tx, + None, + ); + let Ok(prompted) = agent_from_response(&request_id, &prompt_response) else { + return Ok(Some(prompt_response)); + }; + if !agent_wait_identity_matches( + &prompted, + &before_prompt.terminal_id, + before_prompt.name.as_deref().filter(|name| *name == target), + before_prompt.agent.as_deref(), + ) { + return agent_wait_not_running(request_id).map(Some); + } + + // The prompt response is produced on the app thread after input is queued. + // Requiring a later sequence prevents pre-prompt completion from satisfying this wait. + let prompt_state_change_seq = prompted.state_change_seq; + let Some(outcome) = wait_for_resolved_agent( + request_id.clone(), + ResolvedAgentWait { + target, + until: agent_wait_statuses(wait.until), + timeout_ms: wait.timeout_ms, + initial: prompted, + last_event_sequence, + after_state_change_seq: Some(prompt_state_change_seq), + accept_transient_status: false, + }, + stream, + api_tx, + event_hub, + running, + )? + else { + return Ok(None); + }; + let agent = match outcome { + AgentWaitOutcome::Matched(agent) => *agent, + AgentWaitOutcome::Response(response) => return Ok(Some(response)), + }; + serde_json::to_string(&SuccessResponse { + id: request_id, + result: ResponseResult::AgentPrompted { agent }, + }) + .map(Some) + .map_err(std::io::Error::other) +} + +struct ResolvedAgentWait { + target: String, + until: Vec<crate::api::schema::AgentStatus>, + timeout_ms: Option<u64>, + initial: crate::api::schema::AgentInfo, + last_event_sequence: u64, + after_state_change_seq: Option<u64>, + accept_transient_status: bool, +} + +enum AgentWaitOutcome { + Matched(Box<crate::api::schema::AgentInfo>), + Response(String), +} + +fn wait_for_resolved_agent( + request_id: String, + wait: ResolvedAgentWait, + stream: &mut LocalStream, + api_tx: &ApiRequestSender, + event_hub: &EventHub, + running: &Arc<AtomicBool>, +) -> std::io::Result<Option<AgentWaitOutcome>> { + let deadline = wait + .timeout_ms + .map(|ms| std::time::Instant::now() + std::time::Duration::from_millis(ms)); + let expected_terminal_id = wait.initial.terminal_id.clone(); + let expected_name = wait + .initial + .name + .as_ref() + .filter(|name| name.as_str() == wait.target) + .cloned(); + let expected_agent = wait.initial.agent.clone(); + let pane_id = wait.initial.pane_id.clone(); + let mut last_event_sequence = wait.last_event_sequence; + + loop { + if should_stop_connection(stream, running)? { + return Ok(None); + } + + let mut should_probe = false; + let mut matched_event_status = None; + for (sequence, event) in event_hub.events_after(last_event_sequence) { + last_event_sequence = sequence; + match event.data { + EventData::PaneAgentDetected { + pane_id: event_pane, + agent, + released, + final_status, + .. + } if event_pane == pane_id => { + if released { + if let Some(status) = final_status + .filter(|status| wait.until.contains(status)) + .or(matched_event_status) + { + let mut matched = wait.initial.clone(); + matched.agent_status = status; + return Ok(Some(AgentWaitOutcome::Matched(Box::new(matched)))); + } + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + if agent.is_some() && expected_agent.is_some() && agent != expected_agent { + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + should_probe = true; + } + EventData::PaneAgentStatusChanged { + pane_id: event_pane, + agent_status, + .. + } if event_pane == pane_id => { + if wait.accept_transient_status && wait.until.contains(&agent_status) { + matched_event_status = Some(agent_status); + } + should_probe = true; + } + EventData::PaneUpdated { pane } if pane.pane_id == pane_id => should_probe = true, + EventData::PaneMoved { + previous_pane_id, .. + } if previous_pane_id == pane_id => { + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + EventData::PaneClosed { + pane_id: event_pane, + .. + } + | EventData::PaneExited { + pane_id: event_pane, + .. + } if event_pane == pane_id => { + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + _ => {} + } + } + + if should_probe { + let current = match agent_get(&request_id, &wait.target, api_tx) { + Ok(agent) => agent, + Err(response) => { + return agent_wait_probe_error(response) + .map(AgentWaitOutcome::Response) + .map(Some); + } + }; + if !agent_wait_identity_matches( + ¤t, + &expected_terminal_id, + expected_name.as_deref(), + expected_agent.as_deref(), + ) { + return agent_wait_not_running(request_id) + .map(AgentWaitOutcome::Response) + .map(Some); + } + if let Some(status) = matched_event_status { + let mut matched = current; + matched.agent_status = status; + return Ok(Some(AgentWaitOutcome::Matched(Box::new(matched)))); + } + if agent_wait_matches(¤t, &wait.until, wait.after_state_change_seq) { + return Ok(Some(AgentWaitOutcome::Matched(Box::new(current)))); + } + } + + if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) { + return serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "timeout".into(), + message: "timed out waiting for agent status".into(), + }, + }) + .map(AgentWaitOutcome::Response) + .map(Some) + .map_err(std::io::Error::other); + } + std::thread::sleep(CONNECTION_POLL_INTERVAL); + } +} + +fn agent_wait_statuses( + until: Vec<crate::api::schema::AgentStatus>, +) -> Vec<crate::api::schema::AgentStatus> { + if until.is_empty() { + vec![ + crate::api::schema::AgentStatus::Idle, + crate::api::schema::AgentStatus::Done, + crate::api::schema::AgentStatus::Blocked, + ] + } else { + until + } +} + +fn agent_wait_identity_matches( + agent: &crate::api::schema::AgentInfo, + expected_terminal_id: &str, + expected_name: Option<&str>, + expected_agent: Option<&str>, +) -> bool { + agent.terminal_id == expected_terminal_id + && expected_name.is_none_or(|name| agent.name.as_deref() == Some(name)) + && match (expected_agent, agent.agent.as_deref()) { + (Some(expected), Some(current)) => expected == current, + (Some(_), None) => agent.name.is_some(), + (None, _) => true, + } +} + +fn agent_wait_matches( + agent: &crate::api::schema::AgentInfo, + until: &[crate::api::schema::AgentStatus], + after_state_change_seq: Option<u64>, +) -> bool { + until.contains(&agent.agent_status) + && after_state_change_seq.is_none_or(|baseline| agent.state_change_seq > baseline) +} + +fn agent_get( + request_id: &str, + target: &str, + api_tx: &ApiRequestSender, +) -> Result<crate::api::schema::AgentInfo, ErrorResponse> { + let response = dispatch_to_app_with_timeout( + Request { + id: format!("{request_id}:agent"), + method: Method::AgentGet(crate::api::schema::AgentTarget { + target: target.to_string(), + }), + }, + api_tx, + Some(APP_RESPONSE_TIMEOUT), + ); + agent_from_response(request_id, &response) +} + +fn agent_from_response( + request_id: &str, + response: &str, +) -> Result<crate::api::schema::AgentInfo, ErrorResponse> { + let value: serde_json::Value = serde_json::from_str(response).map_err(|_| ErrorResponse { + id: request_id.into(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode agent response".into(), + }, + })?; + if value.get("error").is_some() { + let error = serde_json::from_value(value["error"].clone()).map_err(|_| ErrorResponse { + id: request_id.into(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode agent error".into(), + }, + })?; + return Err(ErrorResponse { + id: request_id.into(), + error, + }); + } + serde_json::from_value(value["result"]["agent"].clone()).map_err(|_| ErrorResponse { + id: request_id.into(), + error: ErrorBody { + code: "internal_error".into(), + message: "failed to decode agent result".into(), + }, + }) +} + +fn agent_wait_success( + request_id: String, + agent: crate::api::schema::AgentInfo, +) -> std::io::Result<String> { + serde_json::to_string(&SuccessResponse { + id: request_id, + result: ResponseResult::AgentInfo { agent }, + }) + .map_err(std::io::Error::other) +} + +fn agent_wait_not_running(request_id: String) -> std::io::Result<String> { + serde_json::to_string(&ErrorResponse { + id: request_id, + error: ErrorBody { + code: "agent_not_running".into(), + message: "agent is no longer running in the target pane".into(), + }, + }) + .map_err(std::io::Error::other) +} + +fn agent_wait_probe_error(response: ErrorResponse) -> std::io::Result<String> { + if response.error.code == "agent_not_found" { + return agent_wait_not_running(response.id); + } + serde_json::to_string(&response).map_err(std::io::Error::other) +} + pub(super) fn wait_for_event( request_id: String, params: EventsWaitParams, @@ -246,3 +644,35 @@ fn wait_matched_response(request_id: &str, event: serde_json::Value) -> String { }) .unwrap() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_wait_probe_only_translates_agent_disappearance() { + let disappeared = agent_wait_probe_error(ErrorResponse { + id: "wait".into(), + error: ErrorBody { + code: "agent_not_found".into(), + message: "missing".into(), + }, + }) + .unwrap(); + let disappeared: ErrorResponse = serde_json::from_str(&disappeared).unwrap(); + assert_eq!(disappeared.id, "wait"); + assert_eq!(disappeared.error.code, "agent_not_running"); + + let unavailable = agent_wait_probe_error(ErrorResponse { + id: "wait".into(), + error: ErrorBody { + code: "server_unavailable".into(), + message: "timed out waiting for app response".into(), + }, + }) + .unwrap(); + let unavailable: ErrorResponse = serde_json::from_str(&unavailable).unwrap(); + assert_eq!(unavailable.id, "wait"); + assert_eq!(unavailable.error.code, "server_unavailable"); + } +} diff --git a/src/app/actions.rs b/src/app/actions.rs index b21de729..b7d335b1 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -14,6 +14,7 @@ use crate::selection::Selection; use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; +use super::api_helpers::pane_agent_status; use super::state::{ navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after, text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow, @@ -245,6 +246,9 @@ pub struct PaneStateUpdate { pub state: AgentState, pub seen: bool, pub presentation: crate::terminal::EffectivePresentation, + pub agent_name_changed: bool, + pub agent_released: bool, + pub agent_release_status: Option<crate::api::schema::AgentStatus>, } // --------------------------------------------------------------------------- @@ -1041,6 +1045,9 @@ impl AppState { state: change.state, seen, presentation: change.presentation.clone(), + agent_name_changed: false, + agent_released: false, + agent_release_status: None, }; Some(update) }) @@ -2677,7 +2684,7 @@ impl AppState { observed_at, } => self .update_terminal_state(pane_id, |terminal| { - let mutation = terminal.set_detected_state_with_screen_signals_at( + Some(terminal.set_detected_state_with_screen_signals_at( agent, state, visible_blocker, @@ -2685,11 +2692,7 @@ impl AppState { visible_working, process_exited, observed_at, - ); - if process_exited { - terminal.reconcile_managed_agent_at(observed_at, true); - } - Some(mutation) + )) }) .into_iter() .collect(), @@ -2851,16 +2854,27 @@ impl AppState { .attached_terminal_id .clone(); let previous_seen = self.workspaces[ws_idx].pane_state(pane_id)?.seen; - let (mutation, managed_changed) = { + let now = Instant::now(); + let (mutation, managed_changed, agent_name_changed, unchanged_change) = { let terminal = self.terminals.get_mut(&terminal_id)?; + let previous_agent_name = terminal.agent_name.clone(); let mutation = update(terminal)?; - let managed_changed = terminal.reconcile_managed_agent_at(Instant::now(), false); - (mutation, managed_changed) + let managed_changed = terminal.reconcile_managed_agent_at(now, false); + let agent_name_changed = terminal.agent_name != previous_agent_name; + let unchanged_change = (mutation.agent_released || agent_name_changed) + .then(|| terminal.unchanged_effective_state_change_at(now)); + ( + mutation, + managed_changed, + agent_name_changed, + unchanged_change, + ) }; - if mutation.session_ref_changed || managed_changed { + if mutation.session_ref_changed || managed_changed || agent_name_changed { self.mark_session_dirty(); } - let change = mutation.effective_state_change?; + let agent_released = mutation.agent_released; + let change = mutation.effective_state_change.or(unchanged_change)?; if change.previous_state != change.state { self.next_agent_state_change_seq += 1; if let Some(terminal) = self.terminals.get_mut(&terminal_id) { @@ -2881,6 +2895,9 @@ impl AppState { state: change.state, seen, presentation: change.presentation.clone(), + agent_name_changed, + agent_released, + agent_release_status: agent_released.then(|| pane_agent_status(change.state, seen)), }; Some(update) } @@ -2924,7 +2941,7 @@ impl AppState { pane_id: PaneId, ) -> Option<PaneStateUpdate> { let observed_at = std::time::Instant::now(); - self.update_terminal_state(pane_id, |terminal| { + let update = self.update_terminal_state(pane_id, |terminal| { let agent = terminal.effective_known_agent().or(terminal.detected_agent); if agent.is_none() && !terminal.full_lifecycle_hook_authority_active() { return None; @@ -2938,7 +2955,8 @@ impl AppState { true, observed_at, )) - }) + })?; + update.agent_released.then_some(update) } fn apply_pane_state_change( @@ -5063,6 +5081,32 @@ mod tests { } #[test] + fn releasing_an_agent_alias_marks_the_session_dirty() { + let mut state = app_with_workspaces(&["active"]); + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + let terminal_id = state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .attached_terminal_id + .clone(); + let terminal = state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_agent_name("reviewer".into()); + state.session_dirty = false; + + state.handle_app_event(AppEvent::HookAgentReleased { + pane_id, + source: "herdr:pi".into(), + agent_label: "pi".into(), + known_agent: Some(Agent::Pi), + seq: Some(1), + }); + + assert!(state.terminals[&terminal_id].agent_name.is_none()); + assert!(state.session_dirty); + } + + #[test] fn terminal_cwd_report_updates_terminal_cwd_and_marks_session_dirty() { let mut state = app_with_workspaces(&["active"]); let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); @@ -5470,6 +5514,11 @@ mod tests { assert_eq!(update.state, AgentState::Idle); assert_eq!(update.agent_label.as_deref(), Some("pi")); assert_eq!(update.known_agent, Some(Agent::Pi)); + assert!(update.agent_released); + assert_eq!( + update.agent_release_status, + Some(crate::api::schema::AgentStatus::Done) + ); assert!(matches!( state.toast.as_ref().map(|toast| toast.kind), Some(ToastKind::Finished) diff --git a/src/app/agents.rs b/src/app/agents.rs index f95bf77f..ccdd4af6 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -8,6 +8,14 @@ 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); +const INVALID_AGENT_NAME_MESSAGE: &str = "agent name must start with a lowercase letter and contain only lowercase letters, digits, '-' or '_' (1-32 characters)"; + +fn valid_agent_name(name: &str) -> bool { + let mut chars = name.chars(); + matches!(chars.next(), Some('a'..='z')) + && name.len() <= 32 + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '-' | '_')) +} impl App { pub(super) fn collect_agent_infos(&self) -> Vec<crate::api::schema::AgentInfo> { @@ -27,7 +35,7 @@ impl App { } pub(super) fn reconcile_managed_agent_target(&mut self, target: &str) { - let Ok(resolved) = self.resolve_terminal_target(target) else { + let Ok(resolved) = self.resolve_agent_target(target) else { return; }; let Some(terminal_id) = self @@ -55,7 +63,7 @@ impl App { &self, target: &str, ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> { - let resolved = self.resolve_terminal_target(target)?; + let resolved = self.resolve_agent_target(target)?; self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| TerminalTargetError::NotFound { target: target.to_string(), @@ -66,7 +74,7 @@ impl App { &mut self, target: &str, ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> { - let resolved = self.resolve_terminal_target(target)?; + let resolved = self.resolve_agent_target(target)?; self.state .focus_pane_in_workspace(resolved.ws_idx, resolved.pane_id); self.state.mark_active_tab_seen(); @@ -83,12 +91,13 @@ impl App { name: Option<String>, ) -> Result<crate::api::schema::AgentInfo, AgentRenameError> { let resolved = self - .resolve_terminal_target(target) + .resolve_agent_target(target) .map_err(AgentRenameError::Target)?; - let normalized_name = name.and_then(|name| { - let trimmed = name.trim().to_string(); - (!trimmed.is_empty()).then_some(trimmed) - }); + let normalized_name = match name { + Some(name) if valid_agent_name(&name) => Some(name), + Some(_) => return Err(AgentRenameError::InvalidName), + None => None, + }; if let Some(name) = normalized_name.as_deref() { let conflicts = self.agent_name_conflicts(name, &resolved.terminal_id); @@ -113,14 +122,16 @@ impl App { if terminal.managed_agent_launch_pending() { return Err(AgentRenameError::PendingLaunch); } + if terminal.effective_agent_label().is_none() { + return Err(AgentRenameError::NotAgent); + } match normalized_name { - Some(name) => { - terminal.set_agent_name(name.clone()); - terminal.set_manual_label(name); - } + Some(name) => terminal.set_agent_name(name), None => terminal.clear_agent_name(), } self.state.mark_session_dirty(); + self.schedule_session_save(); + self.emit_pane_updated(resolved.ws_idx, resolved.pane_id); self.agent_info(resolved.ws_idx, resolved.pane_id) .ok_or_else(|| { AgentRenameError::Target(TerminalTargetError::NotFound { @@ -133,8 +144,8 @@ impl App { &mut self, params: AgentStartParams, ) -> Result<(crate::api::schema::AgentInfo, Vec<String>), AgentStartError> { - let name = params.name.trim().to_string(); - if name.is_empty() { + let name = params.name; + if !valid_agent_name(&name) { return Err(AgentStartError::InvalidName); } let Some(kind) = crate::detect::parse_agent_label(¶ms.kind) else { @@ -154,7 +165,7 @@ impl App { candidates: conflicts, }); } - let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(¶ms.pane_id) else { return Err(AgentStartError::TargetNotFound(params.pane_id)); }; let terminal_id = self @@ -193,7 +204,6 @@ impl App { return Err(AgentStartError::InvalidTimeout); } - let previous_label = terminal.manual_label.clone(); let now = Instant::now(); let terminal = self .state @@ -201,13 +211,8 @@ impl App { .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(); @@ -226,7 +231,7 @@ impl App { match err { AgentStartError::InvalidName => crate::api::schema::ErrorBody { code: "invalid_agent_name".into(), - message: "agent name must not be empty".into(), + message: INVALID_AGENT_NAME_MESSAGE.into(), }, AgentStartError::UnsupportedKind(kind) => crate::api::schema::ErrorBody { code: "unsupported_agent_kind".into(), @@ -318,6 +323,14 @@ impl App { ) -> crate::api::schema::ErrorBody { match err { AgentRenameError::Target(err) => self.agent_target_error_body(err), + AgentRenameError::InvalidName => crate::api::schema::ErrorBody { + code: "invalid_agent_name".into(), + message: INVALID_AGENT_NAME_MESSAGE.into(), + }, + AgentRenameError::NotAgent => crate::api::schema::ErrorBody { + code: "agent_not_found".into(), + message: "agent target does not currently host an agent".into(), + }, AgentRenameError::PendingLaunch => crate::api::schema::ErrorBody { code: "agent_launch_pending".into(), message: "agent name cannot change while startup is pending".into(), @@ -443,9 +456,35 @@ pub(super) enum AgentStartError { pub(super) enum AgentRenameError { Target(TerminalTargetError), + InvalidName, + NotAgent, PendingLaunch, DuplicateName { name: String, candidates: Vec<crate::api::schema::AgentInfo>, }, } + +#[cfg(test)] +mod tests { + use super::valid_agent_name; + + #[test] + fn agent_names_use_a_small_cli_safe_grammar() { + for name in ["a", "reviewer-one", "reviewer_2", &"a".repeat(32)] { + assert!(valid_agent_name(name), "expected {name:?} to be valid"); + } + for name in [ + "", + " reviewer", + "reviewer ", + "reviewer one", + "Reviewer", + "1reviewer", + "reviewer.one", + &"a".repeat(33), + ] { + assert!(!valid_agent_name(name), "expected {name:?} to be invalid"); + } + } +} diff --git a/src/app/api.rs b/src/app/api.rs index 79402e47..7c963ef4 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -556,13 +556,19 @@ impl App { }; let workspace_id = self.public_workspace_id(update.ws_idx); - if update.previous_agent_label != update.agent_label { + if update.agent_name_changed { + self.emit_pane_updated(update.ws_idx, update.pane_id); + } + + if update.previous_agent_label != update.agent_label || update.agent_released { self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneAgentDetected, data: crate::api::schema::EventData::PaneAgentDetected { pane_id: pane_id.clone(), workspace_id: workspace_id.clone(), agent: update.agent_label.clone(), + released: update.agent_released, + final_status: update.agent_release_status, }, }); } @@ -996,9 +1002,18 @@ impl App { 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::AgentWait(_) => { + return responses::encode_error( + request.id, + "invalid_request", + "agent.wait is handled by the api server", + ); + } 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), + Method::AgentSendKeys(params) => { + return self.handle_agent_send_keys(request.id, params) + } Method::PaneSplit(params) => return self.handle_pane_split(request.id, params), Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params), Method::PaneMove(params) => return self.handle_pane_move(request.id, params), @@ -1876,6 +1891,102 @@ mod tests { } #[test] + fn idle_agent_exit_emits_release_event_without_a_state_change() { + for agent_name in [None, Some("reviewer")] { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub.clone(), + ); + let workspace = crate::workspace::Workspace::test_new("idle-agent-exit"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + if let Some(agent_name) = agent_name { + terminal.set_agent_name(agent_name.into()); + } + + app.handle_internal_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at: std::time::Instant::now(), + }); + + assert!(app.state.terminals[&terminal_id].agent_name.is_none()); + assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!( + &event.data, + crate::api::schema::EventData::PaneAgentDetected { + released: true, + final_status: Some(crate::api::schema::AgentStatus::Idle), + .. + } + ))); + } + } + + #[test] + fn stale_detector_exit_does_not_release_a_newer_hook_owned_agent() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &crate::config::Config::default(), + true, + None, + api_rx, + event_hub.clone(), + ); + let workspace = crate::workspace::Workspace::test_new("stale-agent-exit"); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let observed_at = std::time::Instant::now(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state(Some(Agent::Codex), AgentState::Working); + terminal + .set_hook_authority_at( + "herdr:codex".into(), + "codex".into(), + AgentState::Working, + None, + None, + Some(1), + observed_at + std::time::Duration::from_secs(1), + ) + .unwrap(); + terminal.set_agent_name("reviewer".into()); + + app.handle_internal_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Codex), + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + process_exited: true, + observed_at, + }); + + let terminal = &app.state.terminals[&terminal_id]; + assert_eq!(terminal.state, AgentState::Working); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(!event_hub.events_after(0).iter().any(|(_, event)| matches!( + event.data, + crate::api::schema::EventData::PaneAgentDetected { released: true, .. } + ))); + } + + #[test] fn overlay_exit_layout_updated_uses_restored_zoom_state() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index d1fbf14c..ffb9cb63 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -1,8 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - AgentPromptParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, - PaneReadResult, ReadFormat, ReadSource, ResponseResult, + AgentPromptParams, AgentRenameParams, AgentSendKeysParams, AgentStartParams, AgentTarget, + PaneReadResult, ResponseResult, }; use crate::app::App; @@ -59,7 +59,7 @@ impl App { 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) { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -75,29 +75,12 @@ impl App { 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 { + let Some(expected_agent) = terminal.effective_known_agent() 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); }; @@ -118,13 +101,7 @@ impl App { 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, - }, - ) + encode_success(id, ResponseResult::AgentPrompted { agent }) } pub(super) fn handle_agent_read( @@ -132,7 +109,7 @@ impl App { id: String, params: crate::api::schema::AgentReadParams, ) -> String { - let resolved = match self.resolve_terminal_target(¶ms.target) { + let resolved = match self.resolve_agent_target(¶ms.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -140,21 +117,12 @@ impl App { else { return agent_not_found(id, ¶ms.target); }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - }; + let text = crate::app::api_helpers::read_terminal_snapshot( + pane, + params.source, + params.format, + params.lines, + ); encode_success( id, @@ -178,7 +146,7 @@ impl App { } pub(super) fn handle_agent_explain(&mut self, id: String, target: AgentTarget) -> String { - let resolved = match self.resolve_terminal_target(&target.target) { + let resolved = match self.resolve_agent_target(&target.target) { Ok(resolved) => resolved, Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), }; @@ -248,16 +216,46 @@ impl App { encode_success(id, ResponseResult::AgentExplain { explain: value }) } - pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String { - let resolved = match self.resolve_terminal_target(¶ms.target) { + pub(super) fn handle_agent_send_keys( + &mut self, + id: String, + params: AgentSendKeysParams, + ) -> String { + let resolved = match self.resolve_agent_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)) + else { + return agent_not_found(id, ¶ms.target); + }; + let Some(expected_agent) = self + .state + .terminals + .get(terminal_id) + .and_then(|terminal| terminal.effective_known_agent()) + else { + return agent_not_ready(id, ¶ms.target); + }; let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { return agent_not_found(id, ¶ms.target); }; - if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { - return encode_error(id, "agent_send_failed", err.to_string()); + if !super::super::agents::runtime_hosts_agent(runtime, expected_agent) { + return agent_not_ready(id, ¶ms.target); + } + let encoded = match super::super::api_helpers::encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded) => encoded, + Err(key) => { + return encode_error(id, "invalid_key", format!("unsupported key {key}")); + } + }; + let bytes: Vec<u8> = encoded.into_iter().flatten().collect(); + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "agent_send_keys_failed", err.to_string()); } encode_success(id, ResponseResult::Ok {}) @@ -309,7 +307,7 @@ mod tests { } #[tokio::test] - async fn agent_prompt_brackets_opencode_text_and_falls_back_to_raw_submission() { + async fn agent_prompt_accepts_pane_ids_and_working_agents_atomically() { 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] @@ -317,7 +315,7 @@ mod tests { .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); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( 80, 24, 0, b"", 1, @@ -325,11 +323,13 @@ mod tests { runtime.test_process_pty_bytes(b"\x1b[?2004h"); app.state.insert_test_runtime(pane_id, runtime); + let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); let response = app.handle_agent_prompt( "req".into(), AgentPromptParams { - target: "reviewer".into(), + target: public_pane_id, text: "A != B".into(), + wait: None, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); @@ -351,6 +351,7 @@ mod tests { AgentPromptParams { target: "reviewer".into(), text: "A != B".into(), + wait: None, }, ); let raw: SuccessResponse = serde_json::from_str(&raw).unwrap(); @@ -363,10 +364,48 @@ mod tests { AgentPromptParams { target: "opencode".into(), text: "wrong target".into(), + wait: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); - assert_eq!(error.error.code, "agent_not_ready"); + assert_eq!(error.error.code, "agent_not_found"); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn agent_send_keys_validates_every_key_before_writing() { + 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::Pi), AgentState::Idle); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.state.insert_test_runtime(pane_id, runtime); + + let rejected = app.handle_agent_send_keys( + "req-invalid".into(), + AgentSendKeysParams { + target: "reviewer".into(), + keys: vec!["enter".into(), "not-a-key".into()], + }, + ); + let error: crate::api::schema::ErrorResponse = serde_json::from_str(&rejected).unwrap(); + assert_eq!(error.error.code, "invalid_key"); + assert!(rx.try_recv().is_err()); + + let sent = app.handle_agent_send_keys( + "req-valid".into(), + AgentSendKeysParams { + target: "reviewer".into(), + keys: vec!["up".into(), "enter".into()], + }, + ); + let success: SuccessResponse = serde_json::from_str(&sent).unwrap(); + assert!(matches!(success.result, ResponseResult::Ok {})); + assert_eq!(rx.try_recv().unwrap(), Bytes::from_static(b"\x1b[A\r")); assert!(rx.try_recv().is_err()); } @@ -395,6 +434,7 @@ mod tests { AgentPromptParams { target: "reviewer".into(), text: "A != B".into(), + wait: None, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); @@ -426,7 +466,7 @@ mod tests { let response = app.handle_agent_focus( "req".into(), AgentTarget { - target: "pi".into(), + target: app.public_pane_id(0, pane_id).unwrap(), }, ); @@ -436,4 +476,33 @@ mod tests { }; assert_eq!(agent.agent_status, AgentStatus::Idle); } + + #[test] + fn agent_rename_does_not_replace_the_pane_label() { + 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_manual_label("shell-pane".into()); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + let target = app.public_pane_id(0, pane_id).unwrap(); + + for name in [Some("reviewer".to_string()), None] { + let response = app.handle_agent_rename( + "req".into(), + AgentRenameParams { + target: target.clone(), + name, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!(success.result, ResponseResult::AgentInfo { .. })); + assert_eq!( + app.state.terminals[&terminal_id].manual_label.as_deref(), + Some("shell-pane") + ); + } + } } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 6a5b41fe..483d73a5 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -11,7 +11,7 @@ use crate::api::schema::{ PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason, - PaneZoomResult, ReadFormat, ReadSource, ResponseResult, + PaneZoomResult, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; use crate::app::App; @@ -1179,21 +1179,12 @@ impl App { else { return pane_not_found(id, ¶ms.pane_id); }; - let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; - let text = match params.format { - ReadFormat::Text => match params.source { - ReadSource::Visible => pane.visible_text(), - ReadSource::Recent => pane.recent_text(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - ReadFormat::Ansi => match params.source { - ReadSource::Visible => pane.visible_ansi(), - ReadSource::Recent => pane.recent_ansi(requested_lines), - ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), - ReadSource::Detection => pane.detection_text(), - }, - }; + let text = crate::app::api_helpers::read_terminal_snapshot( + pane, + params.source, + params.format, + params.lines, + ); encode_success( id, @@ -2591,6 +2582,11 @@ mod tests { .clone(); let target = app.state.workspaces[1].tabs[0].root_pane; seed_terminal_states(&mut app); + app.state + .terminals + .get_mut(&source_terminal) + .unwrap() + .set_detected_state(Some(Agent::Pi), AgentState::Idle); let previous_pane_id = app.public_pane_id(0, source).unwrap(); let previous_workspace_id = app.public_workspace_id(0); let target_workspace_id = app.public_workspace_id(1); @@ -2633,6 +2629,11 @@ mod tests { Some(&source_terminal) ); assert_eq!(app.parse_pane_id(&previous_pane_id), Some((0, source))); + assert!(matches!( + app.resolve_agent_target(&previous_pane_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); + assert!(app.resolve_agent_target(&move_result.pane.pane_id).is_ok()); } #[test] diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index c7dc39d2..6c393dac 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -101,6 +101,58 @@ pub(super) fn pane_agent_status( } } +pub(super) fn read_terminal_snapshot( + terminal: &crate::terminal::TerminalRuntime, + source: crate::api::schema::ReadSource, + format: crate::api::schema::ReadFormat, + lines: Option<u32>, +) -> String { + use crate::api::schema::{ReadFormat, ReadSource}; + + let requested_lines = lines.unwrap_or(80).min(1000) as usize; + let text = match (format, source) { + (ReadFormat::Text, ReadSource::Visible) => terminal.visible_text(), + (ReadFormat::Text, ReadSource::Recent) => terminal.recent_text(requested_lines), + (ReadFormat::Text, ReadSource::RecentUnwrapped) => { + terminal.recent_unwrapped_text(requested_lines) + } + (ReadFormat::Text, ReadSource::Detection) => terminal.detection_text(), + (ReadFormat::Ansi, ReadSource::Visible) => terminal.visible_ansi(), + (ReadFormat::Ansi, ReadSource::Recent) => terminal.recent_ansi(requested_lines), + (ReadFormat::Ansi, ReadSource::RecentUnwrapped) => { + terminal.recent_unwrapped_ansi(requested_lines) + } + (ReadFormat::Ansi, ReadSource::Detection) => terminal.detection_text(), + }; + + if lines.is_some() && matches!(source, ReadSource::Visible | ReadSource::Detection) { + last_lines(&text, requested_lines) + } else { + text + } +} + +fn last_lines(text: &str, count: usize) -> String { + if text.is_empty() || count == 0 { + return String::new(); + } + let lines: Vec<_> = text.split_inclusive('\n').collect(); + lines[lines.len().saturating_sub(count)..].concat() +} + +#[cfg(test)] +mod read_snapshot_tests { + use super::last_lines; + + #[test] + fn last_lines_preserves_real_line_endings_and_unicode() { + assert_eq!(last_lines("one\ntwø\n三\n", 2), "twø\n三\n"); + assert_eq!(last_lines("one\ntwo\nthree", 1), "three"); + assert_eq!(last_lines("one\ntwo", 0), ""); + assert_eq!(last_lines("", 2), ""); + } +} + pub(super) fn normalize_reported_agent_label(agent: &str) -> Option<String> { let trimmed = agent.trim(); if trimmed.is_empty() { diff --git a/src/app/ids.rs b/src/app/ids.rs index 9dc4371e..4bebedc6 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -141,4 +141,12 @@ impl App { .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?; Some((ws_idx, pane_id)) } + + pub(crate) fn parse_current_public_pane_id( + &self, + id: &str, + ) -> Option<(usize, crate::layout::PaneId)> { + let (ws_idx, pane_id) = self.parse_pane_id(id)?; + (self.public_pane_id(ws_idx, pane_id).as_deref() == Some(id)).then_some((ws_idx, pane_id)) + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index d556b960..bff7c865 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3847,7 +3847,7 @@ mod tests { } #[test] - fn terminal_target_resolves_terminal_id() { + fn terminal_and_agent_targets_treat_terminal_ids_differently() { let mut app = test_app(); let workspace = Workspace::test_new("terminal-target-id"); let pane = workspace.tabs[0].root_pane; @@ -3857,19 +3857,54 @@ mod tests { app.state.selected = 0; let resolved = app.resolve_terminal_target(&terminal_id).unwrap(); - - assert_eq!(resolved.ws_idx, 0); assert_eq!(resolved.pane_id, pane); assert_eq!(resolved.terminal_id, terminal_id); + + assert!(matches!( + app.resolve_agent_target(&resolved.terminal_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); + } + + #[test] + fn agent_target_rejects_a_pane_that_only_has_a_launch_command() { + let mut app = test_app(); + let workspace = Workspace::test_new("terminal-target-command"); + let pane = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane).unwrap().clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .launch_argv = Some(vec!["just".into(), "dev".into()]); + let pane_id = app.public_pane_id(0, pane).unwrap(); + + assert!(app.resolve_terminal_target(&pane_id).is_ok()); + assert!(matches!( + app.resolve_agent_target(&pane_id), + Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) + )); } #[test] - fn terminal_target_resolves_legacy_pane_id() { + fn terminal_target_resolves_pane_id_for_an_agent() { let mut app = test_app(); let workspace = Workspace::test_new("terminal-target-pane"); let pane = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane).unwrap().to_string(); app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let attached_terminal_id = app.state.workspaces[0].terminal_id(pane).cloned().unwrap(); + app.state + .terminals + .get_mut(&attached_terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); app.state.active = Some(0); app.state.selected = 0; let pane_id = app.public_pane_id(0, pane).unwrap(); @@ -3908,6 +3943,27 @@ mod tests { } #[test] + fn agent_target_treats_legacy_pane_syntax_as_a_name() { + let mut app = test_app(); + let workspace = Workspace::test_new("agent-target-name"); + let pane = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane).unwrap().clone(); + app.state.workspaces = vec![workspace]; + app.state.ensure_test_terminals(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.set_detected_state( + Some(crate::detect::Agent::Pi), + crate::detect::AgentState::Idle, + ); + terminal.set_agent_name("p_1".into()); + + let resolved = app.resolve_agent_target("p_1").unwrap(); + + assert_eq!(resolved.pane_id, pane); + assert_eq!(resolved.terminal_id, terminal_id.to_string()); + } + + #[test] fn terminal_target_reports_missing_target() { let mut app = test_app(); app.state.workspaces = vec![Workspace::test_new("terminal-target-missing")]; diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs index d38d115c..a8fe394a 100644 --- a/src/app/terminal_targets.rs +++ b/src/app/terminal_targets.rs @@ -1,7 +1,5 @@ use super::{api_helpers::pane_agent_status, App}; -// Staged for #00f: the agent CLI will use this resolver once #00e provides terminal ids. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TerminalTarget { pub ws_idx: usize, @@ -10,8 +8,6 @@ pub(crate) struct TerminalTarget { pub terminal_id: String, } -// Staged for #00f: ambiguity details are produced before the agent facade consumes them. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TerminalTargetCandidate { pub terminal_id: String, @@ -22,8 +18,6 @@ pub(crate) struct TerminalTargetCandidate { pub agent_status: crate::api::schema::AgentStatus, } -// Staged for #00f: target errors are specified now and surfaced by agent commands next. -#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum TerminalTargetError { NotFound { @@ -36,8 +30,6 @@ pub(crate) enum TerminalTargetError { } impl App { - // Staged for #00f: current pane APIs stay pane-targeted while agent APIs will call this. - #[allow(dead_code)] pub(crate) fn resolve_terminal_target( &self, target: &str, @@ -51,13 +43,13 @@ impl App { return Ok(resolved); } - if let Some((ws_idx, pane_id)) = self.parse_pane_id(target) { + if let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(target) { if let Some(resolved) = self.terminal_target_for_pane(ws_idx, pane_id) { return Ok(resolved); } } - let name_matches: Vec<_> = self + let agent_matches: Vec<_> = self .terminal_targets() .into_iter() .filter(|candidate| { @@ -71,6 +63,39 @@ impl App { }) }) .collect(); + if let Some(resolved) = self.single_terminal_match(target, agent_matches)? { + return Ok(resolved); + } + + Err(TerminalTargetError::NotFound { + target: target.to_string(), + }) + } + + pub(crate) fn resolve_agent_target( + &self, + target: &str, + ) -> Result<TerminalTarget, TerminalTargetError> { + if let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(target) { + if let Some(resolved) = self + .terminal_target_for_pane(ws_idx, pane_id) + .filter(|resolved| self.target_is_agent(resolved)) + { + return Ok(resolved); + } + } + + let name_matches: Vec<_> = self + .terminal_targets() + .into_iter() + .filter(|candidate| { + self.state + .terminals + .values() + .find(|terminal| terminal.id.to_string() == candidate.terminal_id) + .is_some_and(|terminal| terminal.agent_name.as_deref() == Some(target)) + }) + .collect(); if let Some(resolved) = self.single_terminal_match(target, name_matches)? { return Ok(resolved); } @@ -80,8 +105,14 @@ impl App { }) } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] + fn target_is_agent(&self, target: &TerminalTarget) -> bool { + self.state + .terminals + .values() + .find(|terminal| terminal.id.to_string() == target.terminal_id) + .is_some_and(|terminal| terminal.is_agent_terminal()) + } + fn single_terminal_match( &self, target: &str, @@ -102,8 +133,6 @@ impl App { } } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] fn terminal_targets(&self) -> Vec<TerminalTarget> { self.state .workspaces @@ -127,8 +156,6 @@ impl App { .collect() } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] fn terminal_target_for_pane( &self, ws_idx: usize, @@ -145,8 +172,6 @@ impl App { }) } - // Staged for #00f with resolve_terminal_target. - #[allow(dead_code)] fn terminal_target_candidate( &self, ws_idx: usize, @@ -4,10 +4,8 @@ use serde::Serialize; use crate::api::client::{ApiClient, ApiClientError}; use crate::api::schema::{ - AgentStatus, ClientWindowTitleSetParams, EmptyParams, EventData, EventMatch, EventsWaitParams, - Method, OutputMatch, PaneAgentState, PaneWaitForOutputParams, ReadFormat, ReadSource, Request, - ResponseResult, SplitDirection, SubscriptionEventData, SubscriptionEventEnvelope, - SubscriptionEventKind, + AgentStatus, ClientWindowTitleSetParams, EmptyParams, Method, PaneAgentState, ReadFormat, + ReadSource, Request, SplitDirection, }; mod agent; @@ -59,11 +57,26 @@ pub enum CommandOutcome { NotCli, } +pub(super) fn print_read_response(response: &serde_json::Value) -> std::io::Result<i32> { + if response.get("error").is_some() { + eprintln!("{response}"); + return Ok(1); + } + if let Some(text) = response["result"]["read"]["text"].as_str() { + print!("{text}"); + } + Ok(0) +} + pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { let Some(command) = args.get(1).map(|arg| arg.as_str()) else { return Ok(CommandOutcome::NotCli); }; + if spec::print_requested_help(args)? { + return Ok(CommandOutcome::Handled(0)); + } + let exit_code = match command { "server" => { let Some(exit_code) = server::run_server_command(&args[2..])? else { @@ -84,7 +97,6 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { "terminal" => run_terminal_command(&args[2..])?, "pane" => pane::run_pane_command(&args[2..])?, "plugin" => plugin::run_plugin_command(&args[2..])?, - "wait" => run_wait_command(&args[2..])?, "integration" => integration::run_integration_command(&args[2..])?, "session" => run_session_command(&args[2..])?, _ => return Ok(CommandOutcome::NotCli), @@ -384,26 +396,6 @@ fn run_terminal_command(args: &[String]) -> std::io::Result<i32> { } } -fn run_wait_command(args: &[String]) -> std::io::Result<i32> { - let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { - print_wait_help(); - return Ok(2); - }; - - match subcommand { - "output" => wait_output(&args[1..]), - "agent-status" => wait_agent_status(&args[1..]), - "help" | "--help" | "-h" => { - print_wait_help(); - Ok(0) - } - _ => { - print_wait_help(); - Ok(2) - } - } -} - fn run_session_command(args: &[String]) -> std::io::Result<i32> { let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { print_session_help(); @@ -724,214 +716,6 @@ pub(super) fn parse_attach_target(args: &[String], usage: &str) -> Result<(Strin Ok((target.clone(), takeover)) } -fn wait_output(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex]"); - return Ok(2); - }; - - let pane_id = normalize_pane_id(raw_pane_id); - let mut source = ReadSource::Recent; - let mut lines = None; - let mut timeout_ms = None; - let mut strip_ansi = true; - let mut regex = false; - let mut match_value = None; - - let mut index = 1; - while index < args.len() { - match args[index].as_str() { - "--match" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --match"); - return Ok(2); - }; - match_value = Some(value.clone()); - index += 2; - } - "--source" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --source"); - return Ok(2); - }; - source = parse_read_source(value)?; - index += 2; - } - "--lines" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --lines"); - return Ok(2); - }; - lines = Some(parse_u32_flag("--lines", value)?); - index += 2; - } - "--timeout" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --timeout"); - return Ok(2); - }; - timeout_ms = Some(parse_u64_flag("--timeout", value)?); - index += 2; - } - "--regex" => { - regex = true; - index += 1; - } - "--raw" => { - strip_ansi = false; - index += 1; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - let Some(match_value) = match_value else { - eprintln!("missing required --match"); - return Ok(2); - }; - - let matcher = if regex { - OutputMatch::Regex { value: match_value } - } else { - OutputMatch::Substring { value: match_value } - }; - - let response = send_request(&Request { - id: "cli:wait:output".into(), - method: Method::PaneWaitForOutput(PaneWaitForOutputParams { - pane_id, - source, - lines, - r#match: matcher, - timeout_ms, - strip_ansi, - }), - })?; - - if response.get("error").is_some() { - eprintln!("{}", serde_json::to_string(&response).unwrap()); - return Ok(1); - } - - println!("{}", serde_json::to_string(&response).unwrap()); - Ok(0) -} - -fn wait_agent_status(args: &[String]) -> std::io::Result<i32> { - let Some(raw_pane_id) = args.first() else { - eprintln!("usage: herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS]"); - return Ok(2); - }; - - let pane_id = normalize_pane_id(raw_pane_id); - 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_status(value)?); - index += 2; - } - "--timeout" => { - let Some(value) = args.get(index + 1) else { - eprintln!("missing value for --timeout"); - return Ok(2); - }; - timeout_ms = Some(parse_u64_flag("--timeout", value)?); - index += 2; - } - other => { - eprintln!("unknown option: {other}"); - return Ok(2); - } - } - } - - let Some(agent_status) = desired_status else { - eprintln!("missing required --status"); - return Ok(2); - }; - - wait_for_agent_status_change(pane_id, agent_status, timeout_ms) -} - -fn wait_for_agent_status_change( - pane_id: String, - agent_status: AgentStatus, - timeout_ms: Option<u64>, -) -> std::io::Result<i32> { - let request = Request { - id: "cli:wait:agent-status".into(), - method: Method::EventsWait(EventsWaitParams { - match_event: EventMatch::PaneAgentStatusChanged { - pane_id, - agent_status, - }, - timeout_ms, - }), - }; - let response = send_request(&request)?; - match crate::api::client::parse_response_value(response) { - Ok(success) => { - let ResponseResult::WaitMatched { event } = success.result else { - return Err(std::io::Error::other("unexpected wait response result")); - }; - let EventData::PaneAgentStatusChanged { - pane_id, - workspace_id, - agent_status, - agent, - title, - display_agent, - state_labels, - } = event.data - else { - return Err(std::io::Error::other("unexpected wait event data")); - }; - let event = SubscriptionEventEnvelope { - event: SubscriptionEventKind::PaneAgentStatusChanged, - data: SubscriptionEventData::PaneAgentStatusChanged( - crate::api::schema::PaneAgentStatusChangedEvent { - pane_id, - workspace_id, - agent_status, - agent, - title, - display_agent, - state_labels, - }, - ), - }; - println!( - "{}", - serde_json::to_string(&event).map_err(std::io::Error::other)? - ); - Ok(0) - } - Err(ApiClientError::ErrorResponse(response)) => { - if response.error.code == "timeout" { - eprintln!("timed out waiting for agent status change"); - } else { - eprintln!( - "{}", - serde_json::to_string(&response).map_err(std::io::Error::other)? - ); - } - Ok(1) - } - Err(err) => Err(api_client_error_to_io(err)), - } -} - pub(super) fn print_response(response: &serde_json::Value) -> std::io::Result<i32> { if response.get("error").is_some() { eprintln!("{}", serde_json::to_string(response).unwrap()); @@ -1160,14 +944,6 @@ fn print_terminal_help() { eprintln!(" detach from direct attach with ctrl+b q; send literal ctrl+b with ctrl+b ctrl+b"); } -fn print_wait_help() { - eprintln!("herdr wait commands:"); - eprintln!(" herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw]"); - eprintln!( - " herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS]" - ); -} - fn print_session_help() { eprintln!("herdr session commands:"); eprintln!(" herdr session list [--json]"); diff --git a/src/cli/agent.rs b/src/cli/agent.rs index 898de33c..e48d6b3b 100644 --- a/src/cli/agent.rs +++ b/src/cli/agent.rs @@ -1,8 +1,9 @@ use std::time::{Duration, Instant}; use crate::api::schema::{ - AgentPromptParams, AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, - AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, + AgentPromptParams, AgentPromptWaitOptions, AgentReadParams, AgentRenameParams, + AgentSendKeysParams, AgentStartParams, AgentTarget, AgentWaitParams, EmptyParams, Method, + ReadFormat, ReadSource, Request, }; pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> { @@ -15,7 +16,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> { "list" => agent_list(&args[1..]), "get" => agent_get(&args[1..]), "read" => agent_read(&args[1..]), - "send" => agent_send(&args[1..]), + "send-keys" => agent_send_keys(&args[1..]), "prompt" => agent_prompt(&args[1..]), "rename" => agent_rename(&args[1..]), "focus" => agent_focus(&args[1..]), @@ -304,7 +305,10 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> { eprintln!("missing value for --timeout"); return Ok(2); }; - timeout_ms = Some(super::parse_u64_flag("--timeout", value)?); + timeout_ms = match parse_timeout(value) { + Ok(timeout_ms) => Some(timeout_ms), + Err(exit_code) => return Ok(exit_code), + }; index += 2; } other => { @@ -343,22 +347,20 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> { if response.get("error").is_some() { return super::print_response(&response); } - let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else { + let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000)); + let Some(expected_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, - }, + &pane_id, + timeout, + &expected_kind, + expected_terminal_id, ); match waited { Ok(Ok(agent)) => { @@ -442,23 +444,42 @@ fn agent_attach(args: &[String]) -> std::io::Result<i32> { fn agent_wait(args: &[String]) -> std::io::Result<i32> { let Some(target) = args.first() else { - eprintln!("usage: herdr agent wait <name> [--timeout MS]"); + eprintln!("usage: herdr agent wait <target> [--until STATUS]... [--timeout MS]"); return Ok(2); }; + let mut until = Vec::new(); let mut timeout_ms = None; let mut index = 1; while index < args.len() { match args[index].as_str() { + "--until" => { + let Some(value) = args.get(index + 1) else { + eprintln!("--until requires at least one status"); + return Ok(2); + }; + let status = match super::parse_agent_status(value) { + Ok(status) => status, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + until.push(status); + index += 2; + } "--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)?); + timeout_ms = match parse_timeout(value) { + Ok(timeout_ms) => Some(timeout_ms), + Err(exit_code) => return Ok(exit_code), + }; index += 2; } "help" | "--help" | "-h" => { - eprintln!("usage: herdr agent wait <name> [--timeout MS]"); + eprintln!("usage: herdr agent wait <target> [--until STATUS]... [--timeout MS]"); return Ok(0); } other => { @@ -467,171 +488,71 @@ fn agent_wait(args: &[String]) -> std::io::Result<i32> { } } } - 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 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), - } -} - -#[derive(Clone, Copy)] -enum AgentWaitMode<'a> { - Start { expected_kind: &'a str }, - Current, - AfterPrompt { baseline_state_change_seq: u64 }, + super::print_response(&super::send_request(&Request { + id: "cli:agent:wait".into(), + method: Method::AgentWait(AgentWaitParams { + target: target.clone(), + until, + timeout_ms, + }), + })?) } fn wait_for_named_agent( - lookup_target: &str, - expected_name: &str, - timeout: Option<Duration>, - mode: AgentWaitMode<'_>, + name: &str, + fallback_pane_id: &str, + timeout: Duration, + expected_kind: &str, + expected_terminal_id: &str, ) -> std::io::Result<Result<serde_json::Value, serde_json::Value>> { let started_at = Instant::now(); - let deadline = timeout.and_then(|timeout| started_at.checked_add(timeout)); + let deadline = 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 the server reconcile its matching startup deadline before + // returning so the pending name is immediately reusable. + let _ = resolve_agent_target_unchecked(name, "cli:agent:start:timeout"); + return Ok(Err(agent_wait_timeout())); } - 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 { + let poll_id = "cli:agent:start"; + let mut response = if first_poll { first_poll = false; - resolve_agent_target(lookup_target, poll_id)? + resolve_agent_target(name, poll_id)? } else { - resolve_agent_target_unchecked(lookup_target, poll_id)? + resolve_agent_target_unchecked(name, 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", - ))); + response = resolve_agent_target_unchecked(fallback_pane_id, poll_id)?; + if response.get("error").is_some() { + std::thread::sleep(Duration::from_millis(100)); + continue; } - 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) + let outcome = if agent["terminal_id"].as_str() != Some(expected_terminal_id) { + Some(Err(agent_name_lost_error("cli:agent:start", name))) + } else if let Some(actual) = agent["agent"] + .as_str() + .filter(|actual| *actual != expected_kind) { - 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 - } - } + Some(Err(cli_agent_error( + "cli:agent:start", + "agent_kind_mismatch", + format!("expected {expected_kind}, detected {actual}"), + ))) + } else if agent["name"].as_str() != Some(name) { + Some(Err(agent_name_lost_error("cli:agent:start", name))) + } else if 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 }; if let Some(outcome) = outcome { return Ok(outcome); @@ -659,15 +580,12 @@ fn print_agent_transport_error( super::print_response(&cli_agent_error(request_id, code, err.to_string())) } -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) +fn agent_wait_timeout() -> serde_json::Value { + cli_agent_error( + "cli:agent:start", + "timeout", + "timed out waiting for agent startup", + ) } fn cli_agent_error(id: &str, code: &str, message: impl Into<String>) -> serde_json::Value { @@ -698,18 +616,14 @@ fn agent_get_request(target: &str, request_id: &str) -> Request { } fn agent_rename(args: &[String]) -> std::io::Result<i32> { - let Some(target) = args.first() else { + let [target, value] = args else { eprintln!("usage: herdr agent rename <target> <name>|--clear"); return Ok(2); }; - if args.len() < 2 { - eprintln!("usage: herdr agent rename <target> <name>|--clear"); - return Ok(2); - } - let name = if args.len() == 2 && args[1] == "--clear" { + let name = if value == "--clear" { None } else { - Some(args[1..].join(" ")) + Some(value.clone()) }; super::print_response(&super::send_request(&Request { @@ -723,7 +637,9 @@ fn agent_rename(args: &[String]) -> std::io::Result<i32> { fn agent_prompt(args: &[String]) -> std::io::Result<i32> { let Some(target) = args.first() else { - eprintln!("usage: herdr agent prompt <name> <text> [--wait] [--timeout MS]"); + eprintln!( + "usage: herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS]" + ); return Ok(2); }; let Some(text) = args.get(1) else { @@ -731,6 +647,7 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> { return Ok(2); }; let mut wait = false; + let mut until = Vec::new(); let mut timeout_ms = None; let mut index = 2; while index < args.len() { @@ -739,12 +656,30 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> { wait = true; index += 1; } + "--until" => { + let Some(value) = args.get(index + 1) else { + eprintln!("--until requires at least one status"); + return Ok(2); + }; + let status = match super::parse_agent_status(value) { + Ok(status) => status, + Err(err) => { + eprintln!("{err}"); + return Ok(2); + } + }; + until.push(status); + index += 2; + } "--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)?); + timeout_ms = match parse_timeout(value) { + Ok(timeout_ms) => Some(timeout_ms), + Err(exit_code) => return Ok(exit_code), + }; index += 2; } option => { @@ -753,63 +688,36 @@ fn agent_prompt(args: &[String]) -> std::io::Result<i32> { } } } - let mut response = super::send_request(&Request { + if !until.is_empty() && !wait { + eprintln!("--until requires --wait"); + return Ok(2); + } + if timeout_ms.is_some() && !wait { + eprintln!("--timeout requires --wait"); + return Ok(2); + } + let response = super::send_request(&Request { id: "cli:agent:prompt".into(), method: Method::AgentPrompt(AgentPromptParams { target: target.clone(), text: text.clone(), + wait: wait.then_some(AgentPromptWaitOptions { until, timeout_ms }), }), })?; - 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), - } + super::print_response(&response) } -fn agent_send(args: &[String]) -> std::io::Result<i32> { +fn agent_send_keys(args: &[String]) -> std::io::Result<i32> { if args.len() < 2 { - eprintln!("usage: herdr agent send <target> <text>"); + eprintln!("usage: herdr agent send-keys <target> <key> [key ...]"); return Ok(2); } super::print_response(&super::send_request(&Request { - id: "cli:agent:send".into(), - method: Method::AgentSend(AgentSendParams { + id: "cli:agent:send-keys".into(), + method: Method::AgentSendKeys(AgentSendKeysParams { target: args[0].clone(), - text: args[1..].join(" "), + keys: args[1..].to_vec(), }), })?) } @@ -865,7 +773,7 @@ fn agent_read(args: &[String]) -> std::io::Result<i32> { } } - super::print_response(&super::send_request(&Request { + let response = super::send_request(&Request { id: "cli:agent:read".into(), method: Method::AgentRead(AgentReadParams { target: target.clone(), @@ -874,27 +782,35 @@ fn agent_read(args: &[String]) -> std::io::Result<i32> { format, strip_ansi, }), - })?) + })?; + super::print_read_response(&response) } fn print_agent_help() { eprintln!("herdr agent commands:"); eprintln!(" herdr agent list"); eprintln!(" herdr agent get <target>"); - eprintln!(" herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); - eprintln!(" herdr agent send <target> <text>"); - eprintln!(" herdr agent prompt <name> <text> [--wait] [--timeout MS]"); + eprintln!(" herdr agent read <target> [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi]"); + eprintln!(" herdr agent send-keys <target> <key> [key ...]"); + eprintln!(" herdr agent prompt <target> <text> [--wait] [--until STATUS]... [--timeout MS]"); eprintln!(" herdr agent rename <target> <name>|--clear"); eprintln!(" herdr agent focus <target>"); - eprintln!(" herdr agent wait <name> [--timeout MS]"); + eprintln!(" herdr agent wait <target> [--until STATUS]... [--timeout MS]"); eprintln!(" herdr agent attach <target> [--takeover]"); eprintln!( " herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>]" ); - eprintln!(" herdr agent explain <target> [--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"); + eprintln!(" herdr agent explain <target> [--json|--format text|json] [--verbose]"); eprintln!( - " agent send writes literal text; use pane run when you want command text plus Enter" + " herdr agent explain --file PATH --agent LABEL [--json|--format text|json] [--verbose]" ); + eprintln!(" targets accept unique agent names and pane ids that currently host agents"); + eprintln!(" kinds: {}", super::spec::agent_kind_values().join("|")); +} + +fn parse_timeout(value: &str) -> Result<u64, i32> { + super::parse_u64_flag("--timeout", value).map_err(|err| { + eprintln!("{err}"); + 2 + }) } diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 80e10c69..4448840b 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1,11 +1,11 @@ use crate::api::schema::{ - Method, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, - PaneLayoutParams, PaneListParams, PaneMoveDestination, PaneMoveParams, PaneNeighborParams, - PaneProcessInfoParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams, - PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams, - PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, - PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, - SplitDirection, + Method, OutputMatch, PaneCurrentParams, PaneDirection, PaneEdgesParams, + PaneFocusDirectionParams, PaneLayoutParams, PaneListParams, PaneMoveDestination, + PaneMoveParams, PaneNeighborParams, PaneProcessInfoParams, PaneReadParams, + PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, + PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, + PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneWaitForOutputParams, + PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, SplitDirection, }; pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { @@ -33,6 +33,7 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { "close" => pane_close(&args[1..]), "send-text" => pane_send_text(&args[1..]), "send-keys" => pane_send_keys(&args[1..]), + "wait-output" => pane_wait_output(&args[1..]), "report-agent" => pane_report_agent(&args[1..]), "report-agent-session" => pane_report_agent_session(&args[1..]), "release-agent" => pane_release_agent(&args[1..]), @@ -505,15 +506,7 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> { }), })?; - if let Some(error) = response.get("error") { - eprintln!("{}", serde_json::to_string(error).unwrap()); - return Ok(1); - } - - if let Some(text) = response["result"]["read"]["text"].as_str() { - print!("{text}"); - } - Ok(0) + super::print_read_response(&response) } fn pane_split(args: &[String]) -> std::io::Result<i32> { @@ -941,6 +934,92 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> { })) } +fn pane_wait_output(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane wait-output <pane_id> (--match TEXT | --regex PATTERN) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw]"); + return Ok(2); + }; + let pane_id = super::normalize_pane_id(raw_pane_id); + let mut source = ReadSource::Recent; + let mut lines = None; + let mut timeout_ms = None; + let mut strip_ansi = true; + let mut matcher = None; + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--match" | "--regex" => { + let option = args[index].as_str(); + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for {option}"); + return Ok(2); + }; + if matcher.is_some() { + eprintln!("--match and --regex are mutually exclusive"); + return Ok(2); + } + matcher = Some(if option == "--regex" { + OutputMatch::Regex { + value: value.clone(), + } + } else { + OutputMatch::Substring { + value: value.clone(), + } + }); + index += 2; + } + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = super::parse_read_source(value)?; + index += 2; + } + "--lines" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --lines"); + return Ok(2); + }; + lines = Some(super::parse_u32_flag("--lines", value)?); + index += 2; + } + "--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; + } + "--raw" => { + strip_ansi = false; + index += 1; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + let Some(matcher) = matcher else { + eprintln!("missing required --match or --regex"); + return Ok(2); + }; + super::print_response(&super::send_request(&Request { + id: "cli:pane:wait-output".into(), + method: Method::PaneWaitForOutput(PaneWaitForOutputParams { + pane_id, + source, + lines, + r#match: matcher, + timeout_ms, + strip_ansi, + }), + })?) +} + fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); @@ -1430,6 +1509,7 @@ fn print_pane_help() { eprintln!(" herdr pane close <pane_id>"); eprintln!(" herdr pane send-text <pane_id> <text>"); eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); + eprintln!(" herdr pane wait-output <pane_id> (--match TEXT | --regex PATTERN) [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--raw]"); eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); eprintln!(" herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH]"); eprintln!(" herdr pane release-agent <pane_id> --source ID --agent LABEL [--seq N]"); diff --git a/src/cli/protocol_guard.rs b/src/cli/protocol_guard.rs index 21302bbf..db12b6f8 100644 --- a/src/cli/protocol_guard.rs +++ b/src/cli/protocol_guard.rs @@ -64,13 +64,13 @@ mod tests { #[test] fn older_server_error_preserves_request_id_and_guidance() { let response = mismatch_response( - "cli:wait:agent-status", + "cli:agent:wait", crate::protocol::PROTOCOL_VERSION - 1, "Run the session stop command, then restart.", ) .unwrap(); - assert_eq!(response.id, "cli:wait:agent-status"); + assert_eq!(response.id, "cli:agent:wait"); assert_eq!(response.error.code, "protocol_mismatch"); assert!(response.error.message.contains(&format!( "client protocol {}", diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 31cd834f..7b2977ac 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -1,4 +1,6 @@ -use clap::{Arg, ArgAction, Command, ValueHint}; +use std::io::Write; + +use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint}; pub(super) fn command() -> Command { let command = Command::new("herdr") @@ -36,18 +38,64 @@ pub(super) fn command() -> Command { .subcommand(notification_command()) .subcommand(agent_command()) .subcommand(pane_command()) - .subcommand(wait_command()) .subcommand(terminal_command()) .subcommand(session_command()) .subcommand(integration_command()) .subcommand(plugin_command()); - disable_auto_help(command) + configure_help(command, true) } -fn disable_auto_help(command: Command) -> Command { +fn configure_help(command: Command, root: bool) -> Command { + let command = if root { + command + } else { + command.disable_help_flag(false) + }; command - .disable_help_flag(true) - .mut_subcommands(disable_auto_help) + .disable_help_subcommand(true) + .mut_subcommands(|subcommand| configure_help(subcommand, false)) +} + +pub(super) fn print_requested_help(args: &[String]) -> std::io::Result<bool> { + let mut stdout = std::io::stdout().lock(); + write_requested_help(args, &mut stdout) +} + +fn write_requested_help(args: &[String], output: &mut impl Write) -> std::io::Result<bool> { + let Some(help_index) = args + .iter() + .position(|arg| matches!(arg.as_str(), "--help" | "-h")) + else { + return Ok(false); + }; + if help_index < 2 { + return Ok(false); + } + if args[1..help_index].iter().any(|arg| arg == "--") { + return Ok(false); + } + + let mut root = command(); + root.build(); + let mut selected = &mut root; + let mut path = vec!["herdr".to_string()]; + for segment in &args[1..help_index] { + if selected.find_subcommand(segment).is_none() { + break; + } + path.push(segment.clone()); + selected = selected + .find_subcommand_mut(segment) + .expect("subcommand checked immediately before mutable lookup"); + } + if path.len() == 1 || help_index != path.len() { + return Ok(false); + } + + selected.set_bin_name(path.join(" ")); + selected.write_long_help(&mut *output)?; + writeln!(output)?; + Ok(true) } fn completion_command() -> Command { @@ -164,7 +212,7 @@ fn workspace_command() -> Command { Command::new("report-metadata") .about("Report display-only workspace metadata") .arg(required("workspace_id", "WORKSPACE_ID")) - .arg(option("source", "ID")) + .arg(option("source", "ID").required(true)) .arg(repeatable_option("token", "NAME=VALUE")) .arg(repeatable_option("clear-token", "NAME")) .arg(option("seq", "N")) @@ -279,32 +327,65 @@ fn agent_command() -> Command { .arg(flag("ansi")), ) .subcommand( - Command::new("send") - .about("Send text to an agent") + Command::new("send-keys") + .about("Send key presses to an agent") .arg(required("target", "TARGET")) - .arg(required("text", "TEXT")), + .arg(required("key", "KEY").num_args(1..)) + .after_help("Use esc as the canonical Escape key name; escape is also accepted."), ) .subcommand( Command::new("prompt") - .about("Submit a prompt to a named agent") - .arg(required("name", "NAME")) + .about("Submit a prompt to an agent") + .arg(required("target", "TARGET")) .arg(required("text", "TEXT")) - .arg(flag("wait")) - .arg(option("timeout", "MS")), + .arg( + flag("wait") + .help("Wait for the first matching state observed after submission"), + ) + .arg( + option("until", "STATUS") + .action(ArgAction::Append) + .requires("wait") + .value_parser(["idle", "working", "blocked", "done", "unknown"]) + .help("State to match after --wait; repeat for more than one state"), + ) + .arg( + option("timeout", "MS") + .requires("wait") + .help("Fail after this many milliseconds"), + ) + .after_help( + "Without --until, --wait matches the first idle, done, or blocked state observed after submission. It does not track turns: if the agent is already working, that active turn's completion may match. Use --until unknown explicitly when needed. Without --timeout, it waits indefinitely.", + ), ) .subcommand( Command::new("rename") .about("Rename an agent") + .override_usage("herdr agent rename <TARGET> <NAME>|--clear") .arg(required("target", "TARGET")) .arg(Arg::new("name").value_name("NAME")) - .arg(flag("clear")), + .arg(flag("clear")) + .group( + ArgGroup::new("rename") + .args(["name", "clear"]) + .required(true), + ), ) .subcommand(id_command("focus", "target", "Focus an agent")) .subcommand( Command::new("wait") - .about("Wait until a named agent is no longer working") - .arg(required("name", "NAME")) - .arg(option("timeout", "MS")), + .about("Wait until an agent reaches one of the requested states") + .arg(required("target", "TARGET")) + .arg( + option("until", "STATUS") + .action(ArgAction::Append) + .value_parser(["idle", "working", "blocked", "done", "unknown"]) + .help("State to match; repeat for more than one state"), + ) + .arg(option("timeout", "MS").help("Fail after this many milliseconds")) + .after_help( + "Without --until, matches idle, done, or blocked. Use --until unknown explicitly when needed. Without --timeout, waits indefinitely.", + ), ) .subcommand( Command::new("attach") @@ -316,14 +397,29 @@ fn agent_command() -> Command { Command::new("start") .about("Start a supported interactive agent in an existing pane") .arg(required("name", "NAME")) - .arg(option("kind", "KIND").required(true)) - .arg(option("pane", "ID").required(true)) - .arg(option("timeout", "MS")) + .arg( + option("kind", "KIND") + .required(true) + .value_parser(agent_kind_values()) + .help("Supported agent kind and canonical executable"), + ) + .arg( + option("pane", "ID") + .required(true) + .help("Existing pane at an interactive shell prompt"), + ) + .arg( + option("timeout", "MS") + .help("Wait for interactive readiness (default: 30000; max: 300000)"), + ) .arg( Arg::new("agent_args") .value_name("AGENT_ARG") .num_args(0..) .last(true), + ) + .after_help( + "The pane must be at its interactive shell prompt. Success means the expected agent was detected in the same terminal and is ready for input.", ), ) .subcommand( @@ -343,6 +439,13 @@ fn agent_command() -> Command { ) } +pub(super) fn agent_kind_values() -> Vec<&'static str> { + crate::detect::Agent::ALL + .into_iter() + .map(crate::detect::agent_label) + .collect() +} + fn pane_command() -> Command { Command::new("pane") .about("Control terminal panes") @@ -370,7 +473,7 @@ fn pane_command() -> Command { .subcommand( Command::new("neighbor") .about("Find a pane neighbor") - .arg(direction_option()) + .arg(required_direction_option()) .args(current_pane_args()), ) .subcommand( @@ -381,13 +484,13 @@ fn pane_command() -> Command { .subcommand( Command::new("focus") .about("Focus a neighboring pane") - .arg(direction_option()) + .arg(required_direction_option()) .args(current_pane_args()), ) .subcommand( Command::new("resize") .about("Resize a pane split") - .arg(direction_option()) + .arg(required_direction_option()) .arg(option("amount", "FLOAT")) .args(current_pane_args()), ) @@ -464,7 +567,37 @@ fn pane_command() -> Command { Command::new("send-keys") .about("Send key presses to a pane") .arg(required("pane_id", "PANE_ID")) - .arg(required("key", "KEY").num_args(1..)), + .arg(required("key", "KEY").num_args(1..)) + .after_help("Use esc as the canonical Escape key name; escape is also accepted."), + ) + .subcommand( + Command::new("wait-output") + .about("Wait for matching pane output") + .arg(required("pane_id", "PANE_ID")) + .arg( + option("match", "TEXT") + .conflicts_with("regex") + .required_unless_present("regex") + .help("Match a literal substring"), + ) + .arg( + option("regex", "PATTERN") + .conflicts_with("match") + .required_unless_present("match") + .help("Match a Rust regular expression"), + ) + .arg(read_source_option(false)) + .arg(option("lines", "N").help("Restrict the searched snapshot to N lines")) + .arg(option("timeout", "MS").help("Fail after this many milliseconds")) + .arg(flag("raw").help("Keep ANSI escape sequences while matching")) + .group( + ArgGroup::new("matcher") + .args(["match", "regex"]) + .required(true), + ) + .after_help( + "The selected snapshot is searched immediately, including existing output, then polled. Without --timeout, this waits indefinitely.", + ), ) .subcommand( Command::new("run") @@ -482,8 +615,8 @@ fn report_agent_command() -> Command { Command::new("report-agent") .about("Report pane agent lifecycle state") .arg(required("pane_id", "PANE_ID")) - .arg(option("source", "ID")) - .arg(option("agent", "LABEL")) + .arg(option("source", "ID").required(true)) + .arg(option("agent", "LABEL").required(true)) .arg(pane_agent_state_option("state")) .arg(option("message", "TEXT")) .arg(option("seq", "N")) @@ -495,8 +628,8 @@ fn report_agent_session_command() -> Command { Command::new("report-agent-session") .about("Report pane agent session identity") .arg(required("pane_id", "PANE_ID")) - .arg(option("source", "ID")) - .arg(option("agent", "LABEL")) + .arg(option("source", "ID").required(true)) + .arg(option("agent", "LABEL").required(true)) .arg(option("seq", "N")) .arg(option("agent-session-id", "ID")) .arg(path_option("agent-session-path", "PATH")) @@ -507,8 +640,8 @@ fn release_agent_command() -> Command { Command::new("release-agent") .about("Release pane agent lifecycle authority") .arg(required("pane_id", "PANE_ID")) - .arg(option("source", "ID")) - .arg(option("agent", "LABEL")) + .arg(option("source", "ID").required(true)) + .arg(option("agent", "LABEL").required(true)) .arg(option("seq", "N")) } @@ -516,7 +649,7 @@ fn report_metadata_command() -> Command { Command::new("report-metadata") .about("Report display-only pane metadata") .arg(required("pane_id", "PANE_ID")) - .arg(option("source", "ID")) + .arg(option("source", "ID").required(true)) .arg(option("agent", "LABEL")) .arg(option("applies-to-source", "ID")) .arg(option("title", "TEXT")) @@ -531,29 +664,6 @@ fn report_metadata_command() -> Command { .arg(option("ttl-ms", "N")) } -fn wait_command() -> Command { - Command::new("wait") - .about("Wait for pane output or agent state") - .subcommand( - Command::new("output") - .about("Wait for matching pane output") - .arg(required("pane_id", "PANE_ID")) - .arg(option("match", "TEXT")) - .arg(read_source_option(false)) - .arg(option("lines", "N")) - .arg(option("timeout", "MS")) - .arg(flag("regex")) - .arg(flag("raw")), - ) - .subcommand( - Command::new("agent-status") - .about("Wait for pane agent status") - .arg(required("pane_id", "PANE_ID")) - .arg(status_option("status", true)) - .arg(option("timeout", "MS")), - ) -} - fn terminal_command() -> Command { Command::new("terminal") .about("Attach to or observe raw terminal streams") @@ -567,6 +677,14 @@ fn terminal_command() -> Command { Command::new("session") .about("Work with terminal sessions") .subcommand( + Command::new("control") + .about("Control a terminal stream") + .arg(required("target", "TARGET")) + .arg(flag("takeover")) + .arg(option("cols", "N")) + .arg(option("rows", "N")), + ) + .subcommand( Command::new("observe") .about("Observe a terminal stream") .arg(required("target", "TARGET")) @@ -749,10 +867,14 @@ fn integration_target_arg() -> Arg { Arg::new("target") .value_name("TARGET") .required(true) - .value_parser([ - "pi", "omp", "claude", "codex", "copilot", "devin", "droid", "kimi", "opencode", - "kilo", "hermes", "qodercli", "cursor", - ]) + .value_parser(integration_target_values()) +} + +fn integration_target_values() -> Vec<&'static str> { + crate::api::schema::IntegrationTarget::ALL + .into_iter() + .map(crate::integration::integration_target_label) + .collect() } fn id_command(name: &'static str, id: &'static str, about: &'static str) -> Command { @@ -763,14 +885,12 @@ fn direction_option() -> Arg { option("direction", "DIRECTION").value_parser(["left", "right", "up", "down"]) } -fn split_direction_option() -> Arg { - option("direction", "DIRECTION").value_parser(["right", "down"]) +fn required_direction_option() -> Arg { + direction_option().required(true) } -fn status_option(name: &'static str, required: bool) -> Arg { - option(name, "STATUS") - .required(required) - .value_parser(["idle", "working", "blocked", "done", "unknown"]) +fn split_direction_option() -> Arg { + option("direction", "DIRECTION").value_parser(["right", "down"]) } fn pane_agent_state_option(name: &'static str) -> Arg { @@ -785,7 +905,9 @@ fn read_source_option(include_detection: bool) -> Arg { } else { vec!["visible", "recent", "recent-unwrapped"] }; - option("source", "SOURCE").value_parser(values) + option("source", "SOURCE") + .value_parser(values) + .help("Terminal snapshot source (default: recent)") } fn text_ansi_format_option() -> Arg { @@ -843,7 +965,7 @@ fn path_arg(name: &'static str, value_name: &'static str) -> Arg { #[cfg(test)] mod tests { - use clap::Command; + use clap::{Arg, Command}; fn command_path<'a>(cmd: &'a Command, path: &[&str]) -> &'a Command { let mut current = cmd; @@ -874,6 +996,31 @@ mod tests { .any(|arg| arg.get_long() == Some(option)) } + fn option_arg<'a>(cmd: &'a Command, option: &str) -> &'a Arg { + cmd.get_arguments() + .find(|arg| arg.get_long() == Some(option)) + .unwrap_or_else(|| panic!("missing --{option}")) + } + + fn argument<'a>(cmd: &'a Command, id: &str) -> &'a Arg { + cmd.get_arguments() + .find(|arg| arg.get_id() == id) + .unwrap_or_else(|| panic!("missing argument {id}")) + } + + fn collect_subcommand_paths( + cmd: &Command, + path: &mut Vec<String>, + paths: &mut Vec<Vec<String>>, + ) { + for subcommand in cmd.get_subcommands() { + path.push(subcommand.get_name().to_string()); + paths.push(path.clone()); + collect_subcommand_paths(subcommand, path, paths); + path.pop(); + } + } + fn assert_command_descriptions(cmd: &Command, path: &mut Vec<String>) { if !path.is_empty() { assert!( @@ -896,6 +1043,37 @@ mod tests { } #[test] + fn spec_passes_clap_invariants() { + super::command().debug_assert(); + } + + #[test] + fn every_spec_subcommand_renders_short_and_long_help() { + let mut paths = Vec::new(); + collect_subcommand_paths(&super::command(), &mut Vec::new(), &mut paths); + + for path in paths { + for flag in ["-h", "--help"] { + let mut args = vec!["herdr".to_string()]; + args.extend(path.iter().cloned()); + args.push(flag.to_string()); + let mut output = Vec::new(); + assert!( + super::write_requested_help(&args, &mut output).unwrap(), + "help was not handled for herdr {} {flag}", + path.join(" ") + ); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains(&format!("Usage: herdr {}", path.join(" "))), + "unexpected help for herdr {}: {output}", + path.join(" ") + ); + } + } + } + + #[test] fn spec_includes_completion_alias_and_shells() { let cmd = super::command(); let completion = command_path(&cmd, &["completion"]); @@ -916,6 +1094,93 @@ mod tests { } #[test] + fn spec_matches_all_integration_targets() { + let cmd = super::command(); + let install = command_path(&cmd, &["integration", "install"]); + assert_eq!( + argument(install, "target") + .get_value_parser() + .possible_values() + .unwrap() + .map(|value| value.get_name().to_string()) + .collect::<Vec<_>>(), + crate::api::schema::IntegrationTarget::ALL + .map(crate::integration::integration_target_label) + .map(str::to_string) + ); + } + + #[test] + fn spec_marks_runtime_required_options_as_required() { + for (path, options) in [ + (&["workspace", "report-metadata"][..], &["source"][..]), + (&["pane", "neighbor"][..], &["direction"][..]), + (&["pane", "focus"][..], &["direction"][..]), + (&["pane", "resize"][..], &["direction"][..]), + (&["pane", "report-agent"][..], &["source", "agent"][..]), + ( + &["pane", "report-agent-session"][..], + &["source", "agent"][..], + ), + (&["pane", "release-agent"][..], &["source", "agent"][..]), + (&["pane", "report-metadata"][..], &["source"][..]), + ] { + let cmd = command_path(&super::command(), path).clone(); + for option in options { + assert!( + option_arg(&cmd, option).is_required_set(), + "herdr {} --{option} should be required", + path.join(" ") + ); + } + } + } + + #[test] + fn agent_prompt_until_requires_wait() { + let error = super::command() + .try_get_matches_from([ + "herdr", "agent", "prompt", "reviewer", "hello", "--until", "idle", + ]) + .unwrap_err(); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn agent_rename_requires_exactly_one_name_or_clear() { + for valid in [ + &["herdr", "agent", "rename", "reviewer", "worker"][..], + &["herdr", "agent", "rename", "reviewer", "--clear"][..], + ] { + assert!(super::command().try_get_matches_from(valid).is_ok()); + } + for invalid in [ + &["herdr", "agent", "rename", "reviewer"][..], + &["herdr", "agent", "rename", "reviewer", "worker", "--clear"][..], + ] { + assert!(super::command().try_get_matches_from(invalid).is_err()); + } + + let mut help = Vec::new(); + super::write_requested_help( + &[ + "herdr".to_string(), + "agent".to_string(), + "rename".to_string(), + "--help".to_string(), + ], + &mut help, + ) + .unwrap(); + assert!(String::from_utf8(help) + .unwrap() + .contains("Usage: herdr agent rename <TARGET> <NAME>|--clear")); + } + + #[test] fn spec_includes_nested_plugin_pane_open_options() { let cmd = super::command(); let open = command_path(&cmd, &["plugin", "pane", "open"]); @@ -930,10 +1195,38 @@ mod tests { let cmd = super::command(); let wait = command_path(&cmd, &["agent", "wait"]); assert!(!has_option(wait, "status")); + assert_eq!( + option_values(wait, "until"), + ["idle", "working", "blocked", "done", "unknown"] + ); assert!(has_option(wait, "timeout")); } #[test] + fn spec_matches_refactored_agent_and_pane_commands() { + let cmd = super::command(); + assert!(cmd + .get_subcommands() + .all(|subcommand| subcommand.get_name() != "wait")); + + let agent = command_path(&cmd, &["agent"]); + assert!(agent + .get_subcommands() + .any(|subcommand| subcommand.get_name() == "send-keys")); + assert!(agent + .get_subcommands() + .any(|subcommand| subcommand.get_name() == "wait")); + assert!(agent + .get_subcommands() + .all(|subcommand| subcommand.get_name() != "send")); + + let pane = command_path(&cmd, &["pane"]); + assert!(pane + .get_subcommands() + .any(|subcommand| subcommand.get_name() == "wait-output")); + } + + #[test] fn spec_includes_pane_read_raw_flag() { let cmd = super::command(); let pane_read = command_path(&cmd, &["pane", "read"]); @@ -954,6 +1247,12 @@ mod tests { let cmd = super::command(); let agent_start = command_path(&cmd, &["agent", "start"]); assert!(has_option(agent_start, "kind")); + assert_eq!( + option_values(agent_start, "kind"), + crate::detect::Agent::ALL + .map(crate::detect::agent_label) + .map(str::to_string) + ); 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}"); @@ -964,17 +1263,18 @@ mod tests { } #[test] - fn zsh_completion_contains_public_commands_and_values() { - let mut cmd = super::command(); - let mut output = Vec::new(); - clap_complete::generate(clap_complete::Shell::Zsh, &mut cmd, "herdr", &mut output); - let script = String::from_utf8(output).unwrap(); - assert!(script.contains("#compdef herdr")); - assert!(script.contains("--help")); - assert!(script.contains("'completion:Generate shell completion scripts'")); - assert!(script.contains("bash elvish fish powershell zsh")); - assert!(script.contains("'pane:Control terminal panes'")); - assert!(script.contains("idle working blocked done unknown")); - assert!(!script.contains("live-handoff")); + fn completion_generation_succeeds_for_every_supported_shell() { + for shell in [ + clap_complete::Shell::Bash, + clap_complete::Shell::Elvish, + clap_complete::Shell::Fish, + clap_complete::Shell::PowerShell, + clap_complete::Shell::Zsh, + ] { + let mut cmd = super::command(); + let mut output = Vec::new(); + clap_complete::generate(shell, &mut cmd, "herdr", &mut output); + assert!(!output.is_empty(), "empty {shell:?} completion output"); + } } } diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 53c3213a..b8eb8f60 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -65,7 +65,6 @@ pub enum Agent { } impl Agent { - #[cfg(test)] pub const ALL: [Self; 21] = [ Self::Pi, Self::Claude, diff --git a/src/main.rs b/src/main.rs index 42ec3d01..a73423bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -554,7 +554,6 @@ fn main() -> io::Result<()> { println!(" herdr notification <subcommand> ..."); println!(" herdr agent <subcommand> ..."); println!(" herdr pane <subcommand> ..."); - println!(" herdr wait <subcommand> ..."); println!(" herdr session <subcommand> ..."); println!(" herdr integration <subcommand> ..."); println!(); @@ -613,10 +612,6 @@ fn main() -> io::Result<()> { "Pane control helpers over the socket API", ), ( - "herdr wait <subcommand>", - "Blocking wait helpers over the socket API", - ), - ( "herdr session <subcommand>", "Manage named persistent sessions", ), @@ -676,7 +671,7 @@ fn main() -> io::Result<()> { if arg.starts_with('-') && !known_flags.contains(&arg_name) { eprintln!("unknown option: {arg}"); eprintln!("run 'herdr --help' for usage"); - std::process::exit(1); + std::process::exit(2); } if !arg.starts_with('-') && ![ @@ -690,7 +685,6 @@ fn main() -> io::Result<()> { "workspace", "worktree", "pane", - "wait", "session", "integration", ] @@ -698,7 +692,7 @@ fn main() -> io::Result<()> { { eprintln!("unknown command: {arg}"); eprintln!("run 'herdr --help' for usage"); - std::process::exit(1); + std::process::exit(2); } } diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 2197e2bb..e442ac17 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -501,6 +501,8 @@ fn restore_tab( }; pane_restore_startup(saved_agent_session, saved_history, &mut agent_restore) }; + let restored_agent_session = + restored_terminal_agent_session(saved_agent_session, startup.duplicate_agent_session); let initial_restore_agent = startup .restore_plan .as_ref() @@ -533,11 +535,14 @@ fn restore_tab( if let Some(label) = saved_label { terminal.set_manual_label(label); } + if let Some(session) = restored_agent_session { + terminal.set_persisted_agent_session(session); + } 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), + (Some(_), None) => {} (None, _) => {} } if let Some(agent) = initial_restore_agent { @@ -551,12 +556,6 @@ fn restore_tab( std::time::Instant::now(), ); } - if let Some(session) = restored_terminal_agent_session( - saved_agent_session, - startup.duplicate_agent_session, - ) { - terminal.set_persisted_agent_session(session); - } panes.insert(*id, PaneState::new(terminal_id)); terminals.push(terminal); continue; @@ -630,12 +629,16 @@ fn restore_tab( if let Some(label) = saved_label { terminal.set_manual_label(label); } + if let Some(session) = restored_agent_session { + terminal.set_persisted_agent_session(session); + } 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), + (Some(agent_name), None) if was_imported => terminal.set_agent_name(agent_name), + (Some(_), None) => {} (None, _) => {} } if let Some(agent) = initial_restore_agent { @@ -649,12 +652,6 @@ fn restore_tab( std::time::Instant::now(), ); } - if let Some(session) = restored_terminal_agent_session( - saved_agent_session, - startup.duplicate_agent_session, - ) { - terminal.set_persisted_agent_session(session); - } panes.insert(*id, PaneState::new(terminal_id.clone())); terminal_runtimes.insert(terminal_id, runtime); terminals.push(terminal); @@ -1326,7 +1323,7 @@ mod tests { } #[tokio::test] - async fn restore_with_gapped_public_tab_numbers_keeps_agent_panel_tab_indices() { + async fn cold_restore_with_gapped_public_tab_numbers_drops_unmanaged_agent_name() { let cwd = std::env::current_dir().unwrap(); let pane_snap = |id: &str| { ( @@ -1424,19 +1421,16 @@ mod tests { ); let workspace = workspaces.first().expect("workspace should restore"); - let agent_pane = workspace.tabs[3].root_pane; - let detail = workspace - .pane_details(&terminals) - .into_iter() - .find(|detail| detail.pane_id == agent_pane) - .expect("restored agent pane should be listed"); - assert_eq!(workspace.active_tab, 3); assert_eq!(workspace.tabs[3].number, 5); - assert_eq!(detail.tab_idx, 3); - assert_eq!(detail.agent_label, "planner"); + let agent_pane = workspace.tabs[3].root_pane; let terminal_id = &workspace.tabs[3].panes[&agent_pane].attached_terminal_id; + assert!(terminals[terminal_id].agent_name.is_none()); assert_eq!(terminals[terminal_id].managed_agent_kind(), None); + assert!(workspace + .pane_details(&terminals) + .into_iter() + .all(|detail| detail.pane_id != agent_pane)); } #[test] diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs index 225d609a..27ac8528 100644 --- a/src/terminal/metadata.rs +++ b/src/terminal/metadata.rs @@ -237,6 +237,7 @@ impl TerminalState { Some(TerminalStateMutation { effective_state_change, session_ref_changed: false, + agent_released: false, }) } pub fn effective_title(&self) -> Option<String> { @@ -319,6 +320,7 @@ impl TerminalState { now, ), session_ref_changed: false, + agent_released: false, }) } diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 815a8b80..38e4739e 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -49,6 +49,7 @@ enum ManagedAgentPhase { Pending { ready_after: Option<Instant>, deadline: Instant, + observed_expected: bool, }, Active, } @@ -56,7 +57,6 @@ enum ManagedAgentPhase { #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ManagedAgent { kind: Agent, - observed_expected: bool, phase: ManagedAgentPhase, } @@ -82,6 +82,13 @@ pub(crate) struct TerminalTitleChange { pub struct TerminalStateMutation { pub effective_state_change: Option<EffectiveStateChange>, pub session_ref_changed: bool, + pub agent_released: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentNameOwner { + agent_label: String, + session_ref: Option<crate::agent_resume::AgentSessionRef>, } /// Pure state for a server-owned terminal. @@ -103,6 +110,7 @@ pub struct TerminalState { pub terminal_title: Option<String>, pub manual_label: Option<String>, pub agent_name: Option<String>, + agent_name_owner: Option<AgentNameOwner>, managed_agent: Option<ManagedAgent>, hook_report_sequences: HashMap<String, u64>, suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>, @@ -134,6 +142,7 @@ impl TerminalState { terminal_title: None, manual_label: None, agent_name: None, + agent_name_owner: None, managed_agent: None, hook_report_sequences: HashMap::new(), suppressed_full_lifecycle_hook_reports: HashMap::new(), @@ -259,6 +268,9 @@ impl TerminalState { let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_detected_agent = self.detected_agent; let previous_session = self.current_session_identity_for_persistence(); + let agent_released = process_exited + && self.hook_authority_not_newer_than(now) + && (previous_agent_label.is_some() || self.agent_name.is_some()); if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) { if self .hook_authority @@ -278,6 +290,7 @@ impl TerminalState { ), session_ref_changed: previous_session != self.current_session_identity_for_persistence(), + agent_released: false, }; } if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) { @@ -291,9 +304,14 @@ impl TerminalState { ), session_ref_changed: previous_session != self.current_session_identity_for_persistence(), + agent_released: false, }; } self.detected_agent = agent; + if let Some(agent) = agent { + let agent_label = crate::detect::agent_label(agent); + self.reconcile_agent_name_owner(agent_label, None); + } if !process_exited { self.clear_full_lifecycle_hook_suppression_for_detected_agent( previous_detected_agent, @@ -358,6 +376,9 @@ impl TerminalState { self.hook_authority = None; self.persisted_agent_session = durable_session; } + if agent_released { + self.clear_agent_name(); + } TerminalStateMutation { effective_state_change: self.recompute_effective_state( previous_agent_label, @@ -368,6 +389,7 @@ impl TerminalState { ), session_ref_changed: previous_session != self.current_session_identity_for_persistence(), + agent_released, } } @@ -486,6 +508,7 @@ impl TerminalState { let previous_state = self.state; let previous_presentation = self.effective_presentation_for_state_at(previous_state, now); let previous_session = self.current_session_identity_for_persistence(); + self.reconcile_agent_name_owner(&agent_label, session_ref.as_ref()); if foreground_takeover_allowed { self.suppress_current_full_lifecycle_hook_authority( FullLifecycleHookSuppressionReason::HookClear, @@ -521,6 +544,7 @@ impl TerminalState { now, ), session_ref_changed: previous_session != current_session, + agent_released: false, }) } @@ -1069,6 +1093,7 @@ impl TerminalState { ); self.hook_authority = None; } + self.reconcile_agent_name_owner(&agent_label, Some(&session_ref)); self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession { source, agent: agent_label, @@ -1084,6 +1109,7 @@ impl TerminalState { now, ), session_ref_changed: previous_session != current_session, + agent_released: false, }) } @@ -1200,6 +1226,7 @@ impl TerminalState { now, ), session_ref_changed: previous_session.is_some(), + agent_released: false, }) } @@ -1256,6 +1283,7 @@ impl TerminalState { self.fallback_visible_blocker = false; self.fallback_observed_at = None; self.hook_authority = None; + self.clear_agent_name(); if !preserve_foreign_persisted_session { self.persisted_agent_session = None; } @@ -1269,6 +1297,7 @@ impl TerminalState { now, ), session_ref_changed: previous_session != current_session, + agent_released: true, }) } @@ -1286,6 +1315,23 @@ impl TerminalState { self.detected_agent } + pub(crate) fn unchanged_effective_state_change_at(&self, now: Instant) -> EffectiveStateChange { + let agent_label = self.effective_agent_label().map(str::to_string); + let known_agent = self.effective_known_agent(); + let state = self.state; + let presentation = self.effective_presentation_for_state_at(state, now); + EffectiveStateChange { + previous_agent_label: agent_label.clone(), + previous_known_agent: known_agent, + previous_state: state, + previous_presentation: presentation.clone(), + agent_label, + known_agent, + state, + presentation, + } + } + pub fn full_lifecycle_hook_authority_active(&self) -> bool { self.live_full_lifecycle_hook_authority() } @@ -1319,8 +1365,30 @@ impl TerminalState { } pub fn set_agent_name(&mut self, name: String) { - let name = name.trim().to_string(); self.agent_name = (!name.is_empty()).then_some(name); + self.agent_name_owner = self.agent_name.as_ref().and_then(|_| { + self.hook_authority + .as_ref() + .map(|authority| AgentNameOwner { + agent_label: authority.agent_label.clone(), + session_ref: authority.session_ref.clone(), + }) + .or_else(|| { + self.persisted_agent_session + .as_ref() + .map(|session| AgentNameOwner { + agent_label: session.agent.clone(), + session_ref: Some(session.session_ref.clone()), + }) + }) + .or_else(|| { + self.effective_agent_label() + .map(|agent_label| AgentNameOwner { + agent_label: agent_label.to_string(), + session_ref: None, + }) + }) + }); } pub fn begin_managed_agent( @@ -1332,12 +1400,16 @@ impl TerminalState { timeout: Duration, ) { self.set_agent_name(name); + self.agent_name_owner = Some(AgentNameOwner { + agent_label: crate::detect::agent_label(kind).to_string(), + session_ref: None, + }); 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), + observed_expected: false, }, }); } @@ -1348,11 +1420,8 @@ impl TerminalState { } 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) - }) + self.managed_agent + .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active)) } pub fn managed_agent_kind(&self) -> Option<Agent> { @@ -1363,6 +1432,7 @@ impl TerminalState { let ManagedAgentPhase::Pending { ready_after, deadline, + .. } = self.managed_agent?.phase else { return None; @@ -1375,25 +1445,29 @@ impl TerminalState { return false; }; let known_agent = self.effective_known_agent(); - let observed_expected = managed.observed_expected || known_agent == Some(managed.kind); + let observed_expected = match managed.phase { + ManagedAgentPhase::Pending { + observed_expected, .. + } => observed_expected || known_agent == Some(managed.kind), + ManagedAgentPhase::Active => false, + }; 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; + self.clear_agent_name(); return true; } if let ManagedAgentPhase::Pending { ready_after, deadline, + observed_expected: previous_observed_expected, } = managed.phase { if now >= deadline { - self.managed_agent = None; - self.agent_name = None; + self.clear_agent_name(); return true; } if ready_after.is_none_or(|ready_after| now >= ready_after) { @@ -1402,7 +1476,6 @@ impl TerminalState { { self.managed_agent = Some(ManagedAgent { kind: managed.kind, - observed_expected: true, phase: ManagedAgentPhase::Active, }); return true; @@ -1410,19 +1483,23 @@ impl TerminalState { if ready_after.is_some() { self.managed_agent = Some(ManagedAgent { kind: managed.kind, - observed_expected, phase: ManagedAgentPhase::Pending { ready_after: None, deadline, + observed_expected, }, }); return true; } } - if observed_expected != managed.observed_expected { + if observed_expected != previous_observed_expected { self.managed_agent = Some(ManagedAgent { - observed_expected, - ..managed + kind: managed.kind, + phase: ManagedAgentPhase::Pending { + ready_after, + deadline, + observed_expected, + }, }); return true; } @@ -1432,15 +1509,19 @@ impl TerminalState { pub fn restore_managed_agent(&mut self, name: String, kind: Agent) { self.set_agent_name(name); + self.agent_name_owner = Some(AgentNameOwner { + agent_label: crate::detect::agent_label(kind).to_string(), + session_ref: None, + }); self.managed_agent = Some(ManagedAgent { kind, - observed_expected: true, phase: ManagedAgentPhase::Active, }); } pub fn clear_agent_name(&mut self) { self.agent_name = None; + self.agent_name_owner = None; self.managed_agent = None; } @@ -1464,9 +1545,45 @@ impl TerminalState { } pub fn is_agent_terminal(&self) -> bool { - self.agent_name.is_some() - || self.effective_agent_label().is_some() - || self.launch_argv.is_some() + self.agent_name.is_some() || self.effective_agent_label().is_some() + } + + fn reconcile_agent_name_owner( + &mut self, + agent_label: &str, + session_ref: Option<&crate::agent_resume::AgentSessionRef>, + ) { + if self.agent_name.is_none() { + return; + } + if self.managed_agent.is_some_and(|managed| { + crate::detect::parse_agent_label(agent_label) == Some(managed.kind) + }) { + return; + } + match self.agent_name_owner.as_mut() { + Some(owner) + if owner.agent_label != agent_label + || owner + .session_ref + .as_ref() + .zip(session_ref) + .is_some_and(|(current, incoming)| current != incoming) => + { + self.agent_name = None; + self.agent_name_owner = None; + } + Some(owner) if owner.session_ref.is_none() && session_ref.is_some() => { + owner.session_ref = session_ref.cloned(); + } + None => { + self.agent_name_owner = Some(AgentNameOwner { + agent_label: agent_label.to_string(), + session_ref: session_ref.cloned(), + }) + } + _ => {} + } } pub fn border_label(&self, show_agent_labels: bool) -> Option<String> { @@ -1566,7 +1683,11 @@ mod tests { assert!(terminal.managed_agent_interactive_ready()); assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + assert!(terminal.managed_agent_interactive_ready()); + terminal.set_detected_state(None, AgentState::Unknown); + assert!(terminal.managed_agent_interactive_ready()); 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)); @@ -3917,6 +4038,70 @@ mod tests { } #[test] + fn pi_session_replacement_clears_the_previous_sessions_alias() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle); + terminal + .set_agent_session_ref( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::id("pi-old"), + Some(20), + ) + .expect("initial session should be accepted"); + terminal.set_agent_name("reviewer".into()); + + terminal + .set_agent_session_ref_for_session_start( + "herdr:pi".into(), + "pi".into(), + crate::agent_resume::AgentSessionRef::id("pi-new"), + Some(21), + Some("new".into()), + ) + .expect("new should replace the session"); + + assert!(terminal.agent_name.is_none()); + } + + #[test] + fn managed_agent_name_survives_native_session_replacement() { + let mut terminal = test_terminal(); + let now = Instant::now(); + terminal.begin_managed_agent( + "reviewer".into(), + Agent::OpenCode, + now, + Duration::ZERO, + Duration::from_secs(1), + ); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + assert!(terminal.reconcile_managed_agent_at(now, false)); + + for (sequence, session) in [(20, "opencode-old"), (21, "opencode-new")] { + terminal + .set_agent_session_ref_for_session_start( + "herdr:opencode".into(), + "opencode".into(), + crate::agent_resume::AgentSessionRef::id(session), + Some(sequence), + Some("new".into()), + ) + .expect("managed session should be accepted"); + } + + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + assert!(terminal.managed_agent_interactive_ready()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| session.session_ref.value.as_str()), + Some("opencode-new") + ); + } + + #[test] fn opencode_session_ref_without_start_source_does_not_replace_existing() { let mut terminal = test_terminal(); terminal @@ -4352,6 +4537,118 @@ mod tests { } #[test] + fn agent_alias_survives_detection_uncertainty_but_not_replacement_or_release() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Pi), AgentState::Working); + terminal.set_agent_name("reviewer".into()); + + terminal.set_detected_state(None, AgentState::Unknown); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle); + assert!(terminal.agent_name.is_none()); + + terminal.set_agent_name("replacement".into()); + terminal + .release_agent_with_mutation("herdr:codex", "codex", None) + .expect("detected agent release should be accepted"); + assert!(terminal.agent_name.is_none()); + } + + #[test] + fn agent_replacement_clears_alias_owned_by_hook_identity() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:claude".into(), + "claude".into(), + AgentState::Working, + None, + Some(20), + ); + terminal.set_agent_name("reviewer".into()); + + terminal.set_detected_state(Some(Agent::Grok), AgentState::Idle); + + assert!(terminal.agent_name.is_none()); + assert_eq!(terminal.effective_known_agent(), Some(Agent::Grok)); + } + + #[test] + fn accepted_hook_replacement_clears_the_previous_agents_alias() { + let mut terminal = test_terminal(); + terminal + .set_hook_authority_at( + "custom:agent".into(), + "pi".into(), + AgentState::Working, + None, + None, + Some(20), + Instant::now(), + ) + .expect("initial hook should be accepted"); + terminal.set_agent_name("reviewer".into()); + + terminal + .set_hook_authority_at( + "custom:agent".into(), + "claude".into(), + AgentState::Idle, + None, + None, + Some(21), + Instant::now(), + ) + .expect("replacement hook should be accepted"); + + assert!(terminal.agent_name.is_none()); + assert_eq!(terminal.effective_known_agent(), Some(Agent::Claude)); + } + + #[test] + fn accepted_same_kind_hook_owner_replacement_clears_the_alias() { + let mut terminal = test_terminal(); + terminal + .set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("first.jsonl")), + Some(20), + Instant::now(), + ) + .expect("initial hook should be accepted"); + terminal.set_agent_name("reviewer".into()); + terminal + .clear_hook_authority_with_mutation(Some("herdr:pi"), Some(21)) + .expect("hook clear should be accepted"); + assert_eq!(terminal.agent_name.as_deref(), Some("reviewer")); + + terminal + .set_hook_authority_at( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + crate::agent_resume::AgentSessionRef::path(test_session_path("second.jsonl")), + Some(22), + Instant::now(), + ) + .expect("replacement hook should be accepted"); + + assert!(terminal.agent_name.is_none()); + assert_eq!(terminal.effective_known_agent(), Some(Agent::Pi)); + } + + #[test] + fn launch_command_alone_does_not_make_a_terminal_an_agent() { + let terminal = test_terminal().with_launch_argv(vec!["just".into(), "dev".into()]); + + assert!(!terminal.is_agent_terminal()); + } + + #[test] fn release_agent_clears_matching_restored_session_ref_before_detection() { let mut terminal = test_terminal(); terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession { |
