aboutsummaryrefslogtreecommitdiff
path: root/src/cli/agent.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/cli/agent.rs')
-rw-r--r--src/cli/agent.rs529
1 files changed, 372 insertions, 157 deletions
diff --git a/src/cli/agent.rs b/src/cli/agent.rs
index 80148046..898de33c 100644
--- a/src/cli/agent.rs
+++ b/src/cli/agent.rs
@@ -1,8 +1,8 @@
-use std::collections::HashMap;
+use std::time::{Duration, Instant};
use crate::api::schema::{
- AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams, AgentStatus,
- AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request, Subscription,
+ AgentPromptParams, AgentReadParams, AgentRenameParams, AgentSendParams, AgentStartParams,
+ AgentTarget, EmptyParams, Method, ReadFormat, ReadSource, Request,
};
pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
@@ -16,6 +16,7 @@ pub(super) fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
"get" => agent_get(&args[1..]),
"read" => agent_read(&args[1..]),
"send" => agent_send(&args[1..]),
+ "prompt" => agent_prompt(&args[1..]),
"rename" => agent_rename(&args[1..]),
"focus" => agent_focus(&args[1..]),
"wait" => agent_wait(&args[1..]),
@@ -269,82 +270,41 @@ fn matched_rule_region_preview<'a>(
fn agent_start(args: &[String]) -> std::io::Result<i32> {
let Some(name) = args.first() else {
- eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...>");
+ eprintln!("usage: herdr agent start <name> --kind KIND --pane ID [--timeout MS] [-- <agent-args...>]");
return Ok(2);
};
-
- let Some(separator) = args.iter().position(|arg| arg == "--") else {
- eprintln!("usage: herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...>");
- return Ok(2);
- };
- if separator == args.len() - 1 {
- eprintln!("agent start requires argv after --");
- return Ok(2);
- }
-
- let mut cwd = None;
- let mut workspace_id = None;
- let mut tab_id = None;
- let mut split = None;
- let mut focus = false;
- let mut env = HashMap::new();
-
+ let separator = args
+ .iter()
+ .position(|arg| arg == "--")
+ .unwrap_or(args.len());
+ let mut kind = None;
+ let mut pane_id = None;
+ let mut timeout_ms = None;
let mut index = 1;
while index < separator {
match args[index].as_str() {
- "--cwd" => {
- let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
- eprintln!("missing value for --cwd");
- return Ok(2);
- };
- cwd = Some(value.clone());
- index += 2;
- }
- "--workspace" => {
- let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
- eprintln!("missing value for --workspace");
- return Ok(2);
- };
- workspace_id = Some(super::normalize_workspace_id(value));
- index += 2;
- }
- "--tab" => {
+ "--kind" => {
let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
- eprintln!("missing value for --tab");
+ eprintln!("missing value for --kind");
return Ok(2);
};
- tab_id = Some(super::normalize_tab_id(value));
+ kind = Some(value.clone());
index += 2;
}
- "--split" => {
+ "--pane" => {
let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
- eprintln!("missing value for --split");
+ eprintln!("missing value for --pane");
return Ok(2);
};
- split = Some(super::parse_split_direction(value)?);
+ pane_id = Some(super::normalize_pane_id(value));
index += 2;
}
- "--focus" => {
- focus = true;
- index += 1;
- }
- "--no-focus" => {
- focus = false;
- index += 1;
- }
- "--env" => {
+ "--timeout" => {
let Some(value) = args.get(index + 1).filter(|_| index + 1 < separator) else {
- eprintln!("missing value for --env");
+ eprintln!("missing value for --timeout");
return Ok(2);
};
- let (key, value) = match super::parse_env_assignment(value) {
- Ok(pair) => pair,
- Err(err) => {
- eprintln!("{err}");
- return Ok(2);
- }
- };
- env.insert(key, value);
+ timeout_ms = Some(super::parse_u64_flag("--timeout", value)?);
index += 2;
}
other => {
@@ -353,20 +313,63 @@ fn agent_start(args: &[String]) -> std::io::Result<i32> {
}
}
}
-
- super::print_response(&super::send_request(&Request {
+ let Some(kind) = kind else {
+ eprintln!("missing required --kind");
+ return Ok(2);
+ };
+ let Some(pane_id) = pane_id else {
+ eprintln!("missing required --pane");
+ return Ok(2);
+ };
+ let Some(expected_kind) = crate::detect::parse_agent_label(&kind) else {
+ eprintln!("unsupported interactive agent kind: {kind}");
+ return Ok(2);
+ };
+ let expected_kind = crate::detect::agent_label(expected_kind).to_string();
+ let mut response = super::send_request(&Request {
id: "cli:agent:start".into(),
method: Method::AgentStart(AgentStartParams {
name: name.clone(),
- cwd,
- workspace_id,
- tab_id,
- split,
- focus,
- argv: args[separator + 1..].to_vec(),
- env,
+ kind,
+ pane_id: pane_id.clone(),
+ args: if separator < args.len() {
+ args[separator + 1..].to_vec()
+ } else {
+ Vec::new()
+ },
+ timeout_ms,
}),
- })?)
+ })?;
+ if response.get("error").is_some() {
+ return super::print_response(&response);
+ }
+ let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else {
+ return super::print_response(&cli_agent_error(
+ "cli:agent:start",
+ "agent_start_failed",
+ "agent start response did not include terminal_id",
+ ));
+ };
+ let terminal_id = terminal_id.to_string();
+ let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000));
+ let waited = wait_for_named_agent(
+ &terminal_id,
+ name,
+ Some(timeout),
+ AgentWaitMode::Start {
+ expected_kind: &expected_kind,
+ },
+ );
+ match waited {
+ Ok(Ok(agent)) => {
+ response["result"]["agent"] = agent;
+ super::print_response(&response)
+ }
+ Ok(Err(error)) => super::print_response(&error),
+ Err(err) => {
+ print_agent_transport_error(err, "cli:agent:start", "agent_start_transport_failed")
+ }
+ }
}
fn agent_list(args: &[String]) -> std::io::Result<i32> {
@@ -439,24 +442,13 @@ 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 <target> --status <idle|working|blocked|unknown> [--timeout MS]");
+ eprintln!("usage: herdr agent wait <name> [--timeout MS]");
return Ok(2);
};
-
let mut timeout_ms = None;
- let mut desired_status = None;
-
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
- "--status" => {
- let Some(value) = args.get(index + 1) else {
- eprintln!("missing value for --status");
- return Ok(2);
- };
- desired_status = Some(parse_agent_wait_status(value)?);
- index += 2;
- }
"--timeout" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --timeout");
@@ -466,7 +458,7 @@ fn agent_wait(args: &[String]) -> std::io::Result<i32> {
index += 2;
}
"help" | "--help" | "-h" => {
- eprintln!("usage: herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]");
+ eprintln!("usage: herdr agent wait <name> [--timeout MS]");
return Ok(0);
}
other => {
@@ -475,67 +467,234 @@ fn agent_wait(args: &[String]) -> std::io::Result<i32> {
}
}
}
-
- let Some(agent_status) = desired_status else {
- eprintln!("missing required --status");
- return Ok(2);
+ let initial = resolve_agent_target(target, "cli:agent:wait:resolve")?;
+ if initial.get("error").is_some() {
+ return super::print_response(&initial);
+ }
+ let agent = &initial["result"]["agent"];
+ if agent["name"].as_str() != Some(target) {
+ return super::print_response(&cli_agent_error(
+ "cli:agent:wait",
+ "agent_name_not_found",
+ format!("named agent {target} not found"),
+ ));
+ }
+ match agent["agent_status"].as_str().unwrap_or("unknown") {
+ "idle" | "done" | "blocked" => return super::print_response(&initial),
+ "unknown" => {
+ return super::print_response(&cli_agent_error(
+ "cli:agent:wait",
+ "agent_not_running",
+ "agent is no longer running",
+ ))
+ }
+ _ => {}
+ }
+ let Some(terminal_id) = agent["terminal_id"].as_str() else {
+ return super::print_response(&cli_agent_error(
+ "cli:agent:wait",
+ "agent_wait_failed",
+ "agent response did not include terminal_id",
+ ));
};
-
- let response = resolve_agent_target(target, "cli:agent:wait:resolve")?;
- if response.get("error").is_some() {
- eprintln!("{}", serde_json::to_string(&response).unwrap());
- return Ok(1);
+ let timeout = timeout_ms.map(Duration::from_millis);
+ let waited = match wait_for_named_agent(terminal_id, target, timeout, AgentWaitMode::Current) {
+ Ok(waited) => waited,
+ Err(err) => {
+ return print_agent_transport_error(
+ err,
+ "cli:agent:wait",
+ "agent_wait_transport_failed",
+ )
+ }
+ };
+ match waited {
+ Ok(agent) => super::print_response(&serde_json::json!({
+ "id": "cli:agent:wait",
+ "result": { "type": "agent_info", "agent": agent }
+ })),
+ Err(error) => super::print_response(&error),
}
- if response["result"]["agent"]["agent_status"]
- .as_str()
- .is_some_and(|current| agent_wait_status_satisfied(agent_status, current))
- {
- println!("{}", serde_json::to_string(&response).unwrap());
- return Ok(0);
+}
+
+#[derive(Clone, Copy)]
+enum AgentWaitMode<'a> {
+ Start { expected_kind: &'a str },
+ Current,
+ AfterPrompt { baseline_state_change_seq: u64 },
+}
+
+fn wait_for_named_agent(
+ lookup_target: &str,
+ expected_name: &str,
+ timeout: Option<Duration>,
+ mode: AgentWaitMode<'_>,
+) -> 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 mut first_poll = true;
+ loop {
+ if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
+ if matches!(mode, AgentWaitMode::Start { .. }) {
+ // Let the server reconcile its matching startup deadline before
+ // returning so the pending name is immediately reusable.
+ let _ = resolve_agent_target_unchecked(lookup_target, "cli:agent:start:timeout");
+ }
+ return Ok(Err(agent_wait_timeout(mode)));
+ }
+ let poll_id = match mode {
+ AgentWaitMode::Start { .. } => "cli:agent:start",
+ AgentWaitMode::Current => "cli:agent:wait",
+ AgentWaitMode::AfterPrompt { .. } => "cli:agent:prompt",
+ };
+ let response = if first_poll {
+ first_poll = false;
+ resolve_agent_target(lookup_target, poll_id)?
+ } else {
+ resolve_agent_target_unchecked(lookup_target, poll_id)?
+ };
+ if response.get("error").is_some() {
+ if matches!(mode, AgentWaitMode::Start { .. }) {
+ return Ok(Err(cli_agent_error(
+ "cli:agent:start",
+ "agent_start_failed",
+ "agent target disappeared before becoming interactive",
+ )));
+ }
+ return Ok(Err(response));
+ }
+ let agent = &response["result"]["agent"];
+ let status = agent["agent_status"].as_str().unwrap_or("unknown");
+ let completed = matches!(status, "idle" | "done" | "blocked");
+ if !matches!(mode, AgentWaitMode::Start { .. })
+ && agent["name"].as_str() != Some(expected_name)
+ {
+ return Ok(Err(agent_name_lost_error(poll_id, expected_name)));
+ }
+ let outcome = match mode {
+ AgentWaitMode::Start { expected_kind } => {
+ if let Some(actual) = agent["agent"].as_str() {
+ if actual != expected_kind {
+ Some(Err(cli_agent_error(
+ "cli:agent:start",
+ "agent_kind_mismatch",
+ format!("expected {expected_kind}, detected {actual}"),
+ )))
+ } else if agent["name"].as_str() != Some(expected_name) {
+ Some(Err(agent_name_lost_error("cli:agent:start", expected_name)))
+ } else if completed && agent["interactive_ready"].as_bool().unwrap_or(false) {
+ Some(Ok(agent.clone()))
+ } else if !agent["launch_pending"].as_bool().unwrap_or(false) {
+ Some(Err(cli_agent_error(
+ "cli:agent:start",
+ "agent_start_failed",
+ "agent process exited before becoming interactive",
+ )))
+ } else {
+ None
+ }
+ } else if !agent["launch_pending"].as_bool().unwrap_or(false) {
+ Some(Err(cli_agent_error(
+ "cli:agent:start",
+ "agent_start_failed",
+ "agent process exited before becoming interactive",
+ )))
+ } else {
+ None
+ }
+ }
+ AgentWaitMode::Current => {
+ if completed {
+ Some(Ok(agent.clone()))
+ } else if status == "unknown" {
+ Some(Err(cli_agent_error(
+ "cli:agent:wait",
+ "agent_not_running",
+ "agent is no longer running",
+ )))
+ } else {
+ None
+ }
+ }
+ AgentWaitMode::AfterPrompt {
+ baseline_state_change_seq,
+ } => {
+ let sequence = agent["state_change_seq"].as_u64().unwrap_or(0);
+ if sequence > baseline_state_change_seq && completed {
+ Some(Ok(agent.clone()))
+ } else if status == "unknown" {
+ Some(Err(cli_agent_error(
+ "cli:agent:prompt",
+ "agent_not_running",
+ "agent exited while waiting for the prompt",
+ )))
+ } else {
+ None
+ }
+ }
+ };
+ if let Some(outcome) = outcome {
+ return Ok(outcome);
+ }
+ std::thread::sleep(Duration::from_millis(100));
}
+}
- let Some(pane_id) = response["result"]["agent"]["pane_id"].as_str() else {
- eprintln!("agent wait failed: response did not include pane_id");
+fn agent_name_lost_error(request_id: &str, expected_name: &str) -> serde_json::Value {
+ cli_agent_error(
+ request_id,
+ "agent_name_not_found",
+ format!("named agent {expected_name} no longer owns the target terminal"),
+ )
+}
+
+fn print_agent_transport_error(
+ err: std::io::Error,
+ request_id: &str,
+ code: &str,
+) -> std::io::Result<i32> {
+ if super::protocol_mismatch_was_reported(&err) {
return Ok(1);
- };
+ }
+ super::print_response(&cli_agent_error(request_id, code, err.to_string()))
+}
- let subscriptions = if agent_status == AgentStatus::Idle {
- vec![
- Subscription::PaneAgentStatusChanged {
- pane_id: pane_id.to_owned(),
- agent_status: Some(AgentStatus::Idle),
- },
- Subscription::PaneAgentStatusChanged {
- pane_id: pane_id.to_owned(),
- agent_status: Some(AgentStatus::Done),
- },
- ]
- } else {
- vec![Subscription::PaneAgentStatusChanged {
- pane_id: pane_id.to_owned(),
- agent_status: Some(agent_status),
- }]
+fn agent_wait_timeout(mode: AgentWaitMode<'_>) -> serde_json::Value {
+ let (id, message) = match mode {
+ AgentWaitMode::Start { .. } => ("cli:agent:start", "timed out waiting for agent startup"),
+ AgentWaitMode::Current => ("cli:agent:wait", "timed out waiting for agent completion"),
+ AgentWaitMode::AfterPrompt { .. } => {
+ ("cli:agent:prompt", "timed out waiting for prompted work")
+ }
};
+ cli_agent_error(id, "timeout", message)
+}
- super::wait_for_agent_change(
- Request {
- id: "cli:agent:wait".into(),
- method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams {
- subscriptions,
- }),
- },
- timeout_ms,
- "timed out waiting for agent status change",
- )
+fn cli_agent_error(id: &str, code: &str, message: impl Into<String>) -> serde_json::Value {
+ serde_json::json!({
+ "id": id,
+ "error": { "code": code, "message": message.into() }
+ })
}
fn resolve_agent_target(target: &str, request_id: &str) -> std::io::Result<serde_json::Value> {
- super::send_request(&Request {
+ super::send_request(&agent_get_request(target, request_id))
+}
+
+fn resolve_agent_target_unchecked(
+ target: &str,
+ request_id: &str,
+) -> std::io::Result<serde_json::Value> {
+ super::send_request_unchecked(&agent_get_request(target, request_id))
+}
+
+fn agent_get_request(target: &str, request_id: &str) -> Request {
+ Request {
id: request_id.into(),
method: Method::AgentGet(AgentTarget {
target: target.to_owned(),
}),
- })
+ }
}
fn agent_rename(args: &[String]) -> std::io::Result<i32> {
@@ -562,6 +721,84 @@ 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]");
+ return Ok(2);
+ };
+ let Some(text) = args.get(1) else {
+ eprintln!("agent prompt requires text");
+ return Ok(2);
+ };
+ let mut wait = false;
+ let mut timeout_ms = None;
+ let mut index = 2;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--wait" => {
+ wait = true;
+ index += 1;
+ }
+ "--timeout" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --timeout");
+ return Ok(2);
+ };
+ timeout_ms = Some(super::parse_u64_flag("--timeout", value)?);
+ index += 2;
+ }
+ option => {
+ eprintln!("unknown option: {option}");
+ return Ok(2);
+ }
+ }
+ }
+ let mut response = super::send_request(&Request {
+ id: "cli:agent:prompt".into(),
+ method: Method::AgentPrompt(AgentPromptParams {
+ target: target.clone(),
+ text: text.clone(),
+ }),
+ })?;
+ if response.get("error").is_some() || !wait {
+ return super::print_response(&response);
+ }
+ let baseline_state_change_seq = response["result"]["baseline_state_change_seq"]
+ .as_u64()
+ .unwrap_or(0);
+ let Some(terminal_id) = response["result"]["agent"]["terminal_id"].as_str() else {
+ return super::print_response(&cli_agent_error(
+ "cli:agent:prompt",
+ "agent_prompt_failed",
+ "agent prompt response did not include terminal_id",
+ ));
+ };
+ let waited = match wait_for_named_agent(
+ terminal_id,
+ target,
+ timeout_ms.map(Duration::from_millis),
+ AgentWaitMode::AfterPrompt {
+ baseline_state_change_seq,
+ },
+ ) {
+ Ok(waited) => waited,
+ Err(err) => {
+ return print_agent_transport_error(
+ err,
+ "cli:agent:prompt",
+ "agent_prompt_transport_failed",
+ )
+ }
+ };
+ match waited {
+ Ok(agent) => {
+ response["result"]["agent"] = agent;
+ super::print_response(&response)
+ }
+ Err(error) => super::print_response(&error),
+ }
+}
+
fn agent_send(args: &[String]) -> std::io::Result<i32> {
if args.len() < 2 {
eprintln!("usage: herdr agent send <target> <text>");
@@ -640,42 +877,20 @@ fn agent_read(args: &[String]) -> std::io::Result<i32> {
})?)
}
-fn agent_wait_status_satisfied(desired: AgentStatus, current: &str) -> bool {
- match desired {
- AgentStatus::Idle => matches!(current, "idle" | "done"),
- AgentStatus::Working => current == "working",
- AgentStatus::Blocked => current == "blocked",
- AgentStatus::Unknown => current == "unknown",
- AgentStatus::Done => false,
- }
-}
-
-fn parse_agent_wait_status(value: &str) -> std::io::Result<AgentStatus> {
- match value {
- "idle" => Ok(AgentStatus::Idle),
- "working" => Ok(AgentStatus::Working),
- "blocked" => Ok(AgentStatus::Blocked),
- "unknown" => Ok(AgentStatus::Unknown),
- "done" => Err(std::io::Error::other(
- "done is a UI attention state; use idle for CLI agent completion waits",
- )),
- _ => Err(std::io::Error::other(format!(
- "invalid agent status: {value} (expected idle, working, blocked, or unknown)"
- ))),
- }
-}
-
fn print_agent_help() {
eprintln!("herdr agent commands:");
eprintln!(" herdr agent list");
eprintln!(" herdr agent get <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 rename <target> <name>|--clear");
eprintln!(" herdr agent focus <target>");
- eprintln!(" herdr agent wait <target> --status <idle|working|blocked|unknown> [--timeout MS]");
+ eprintln!(" herdr agent wait <name> [--timeout MS]");
eprintln!(" herdr agent attach <target> [--takeover]");
- eprintln!(" herdr agent start <name> [--cwd PATH] [--workspace ID] [--tab ID] [--split right|down] [--env KEY=VALUE] [--focus|--no-focus] -- <argv...>");
+ 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");