aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 23:52:20 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 23:56:07 +0300
commit3f80947678a9723434aa7fddfe23750b5393e24c (patch)
tree6f463797132acfc51dfb6e135820a052954b4f13 /src
parentf76f895efe5b9f19aa9db909354c6c0f2195630b (diff)
feat: make agent automation a first-class cli primitive
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs6
-rw-r--r--src/api/schema/agents.rs23
-rw-r--r--src/api/schema/events.rs4
-rw-r--r--src/api/schema/integrations.rs19
-rw-r--r--src/api/schema/response.rs1
-rw-r--r--src/api/schema/tests.rs26
-rw-r--r--src/api/server.rs110
-rw-r--r--src/api/wait.rs430
-rw-r--r--src/app/actions.rs75
-rw-r--r--src/app/agents.rs83
-rw-r--r--src/app/api.rs115
-rw-r--r--src/app/api/agents.rs177
-rw-r--r--src/app/api/panes.rs33
-rw-r--r--src/app/api_helpers.rs52
-rw-r--r--src/app/ids.rs8
-rw-r--r--src/app/mod.rs64
-rw-r--r--src/app/terminal_targets.rs61
-rw-r--r--src/cli.rs258
-rw-r--r--src/cli/agent.rs384
-rw-r--r--src/cli/pane.rs112
-rw-r--r--src/cli/protocol_guard.rs4
-rw-r--r--src/cli/spec.rs458
-rw-r--r--src/detect/mod.rs1
-rw-r--r--src/main.rs10
-rw-r--r--src/persist/restore.rs42
-rw-r--r--src/terminal/metadata.rs2
-rw-r--r--src/terminal/state.rs341
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, &params.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, &params.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(
+ &current,
+ &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(&current, &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(&params.kind) else {
@@ -154,7 +165,7 @@ impl App {
candidates: conflicts,
});
}
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((ws_idx, pane_id)) = self.parse_current_public_pane_id(&params.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(&params.target) {
+ let resolved = match self.resolve_agent_target(&params.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, &params.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, &params.target);
};
if terminal.managed_agent_launch_pending() {
return agent_not_ready(id, &params.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, &params.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, &params.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, &params.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(&params.target) {
+ let resolved = match self.resolve_agent_target(&params.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, &params.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(&params.target) {
+ pub(super) fn handle_agent_send_keys(
+ &mut self,
+ id: String,
+ params: AgentSendKeysParams,
+ ) -> String {
+ let resolved = match self.resolve_agent_target(&params.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, &params.target);
+ };
+ let Some(expected_agent) = self
+ .state
+ .terminals
+ .get(terminal_id)
+ .and_then(|terminal| terminal.effective_known_agent())
+ else {
+ return agent_not_ready(id, &params.target);
+ };
let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else {
return agent_not_found(id, &params.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, &params.target);
+ }
+ let encoded = match super::super::api_helpers::encode_api_keys(runtime, &params.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, &params.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,
diff --git a/src/cli.rs b/src/cli.rs
index ecca934e..9d7389b5 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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 {