From 3f80947678a9723434aa7fddfe23750b5393e24c Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 19 Jul 2026 23:52:20 +0300 Subject: feat: make agent automation a first-class cli primitive --- src/cli/agent.rs | 384 ++++++++++++++++++++++--------------------------------- 1 file changed, 150 insertions(+), 234 deletions(-) (limited to 'src/cli/agent.rs') 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 { @@ -15,7 +16,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result { "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 { 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 { 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 { fn agent_wait(args: &[String]) -> std::io::Result { let Some(target) = args.first() else { - eprintln!("usage: herdr agent wait [--timeout MS]"); + eprintln!("usage: herdr agent wait [--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 [--timeout MS]"); + eprintln!("usage: herdr agent wait [--until STATUS]... [--timeout MS]"); return Ok(0); } other => { @@ -467,171 +488,71 @@ fn agent_wait(args: &[String]) -> std::io::Result { } } } - 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, - mode: AgentWaitMode<'_>, + name: &str, + fallback_pane_id: &str, + timeout: Duration, + expected_kind: &str, + expected_terminal_id: &str, ) -> std::io::Result> { 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) -> 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 { - let Some(target) = args.first() else { + let [target, value] = args else { eprintln!("usage: herdr agent rename |--clear"); return Ok(2); }; - if args.len() < 2 { - eprintln!("usage: herdr agent rename |--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 { fn agent_prompt(args: &[String]) -> std::io::Result { let Some(target) = args.first() else { - eprintln!("usage: herdr agent prompt [--wait] [--timeout MS]"); + eprintln!( + "usage: herdr agent prompt [--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 { 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 { 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 { } } } - 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 { +fn agent_send_keys(args: &[String]) -> std::io::Result { if args.len() < 2 { - eprintln!("usage: herdr agent send "); + eprintln!("usage: herdr agent send-keys [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 { } } - 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 { format, strip_ansi, }), - })?) + })?; + super::print_read_response(&response) } fn print_agent_help() { eprintln!("herdr agent commands:"); eprintln!(" herdr agent list"); eprintln!(" herdr agent get "); - eprintln!(" herdr agent read [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); - eprintln!(" herdr agent send "); - eprintln!(" herdr agent prompt [--wait] [--timeout MS]"); + eprintln!(" herdr agent read [--source visible|recent|recent-unwrapped|detection] [--lines N] [--format text|ansi] [--ansi]"); + eprintln!(" herdr agent send-keys [key ...]"); + eprintln!(" herdr agent prompt [--wait] [--until STATUS]... [--timeout MS]"); eprintln!(" herdr agent rename |--clear"); eprintln!(" herdr agent focus "); - eprintln!(" herdr agent wait [--timeout MS]"); + eprintln!(" herdr agent wait [--until STATUS]... [--timeout MS]"); eprintln!(" herdr agent attach [--takeover]"); eprintln!( " herdr agent start --kind KIND --pane ID [--timeout MS] [-- ]" ); - eprintln!(" herdr agent explain [--json]"); - eprintln!(" herdr agent explain --file PATH --agent LABEL [--json]"); - eprintln!(" targets accept terminal ids, unique agent names, detected/reported agent labels, and legacy pane ids"); + eprintln!(" herdr agent explain [--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 { + super::parse_u64_flag("--timeout", value).map_err(|err| { + eprintln!("{err}"); + 2 + }) } -- cgit v1.2.3