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