aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-09 03:31:37 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-09 03:31:37 +0300
commita3ecb4e3b4151e840ad237e073d8282e1181c34a (patch)
tree12c6924cc65c2b9c5da45df2e3a2fde188f07681
parent05a6d89e33e722376688c679ab0d7322014814a1 (diff)
feat(api): expose done as agent status
-rw-r--r--SKILL.md21
-rw-r--r--SOCKET_API.md44
-rw-r--r--src/api/mod.rs43
-rw-r--r--src/api/schema.rs58
-rw-r--r--src/app/mod.rs39
-rw-r--r--src/cli.rs65
-rw-r--r--tests/api_ping.rs142
-rw-r--r--tests/cli_wrapper.rs119
8 files changed, 400 insertions, 131 deletions
diff --git a/SKILL.md b/SKILL.md
index 1cf0a795..79b71dea 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -29,12 +29,11 @@ if you need the raw protocol or full api reference, read [`SOCKET_API.md`](./SOC
**panes** are terminal splits inside a tab. each pane runs its own process — a shell, an agent, a server, anything.
-**agent state** is detected automatically by herdr. each pane can be:
+**agent status** is detected automatically by herdr. the api exposes one public field for it:
-- `idle` — agent finished and the state is calm
-- `working` — agent is actively working
-- `blocked` — agent needs input or approval
-- `unknown` — no recognized agent
+- `agent_status` — `idle`, `working`, `blocked`, `done`, `unknown`
+
+`done` means the agent finished, but you have not looked at that finished pane yet.
plain shells still exist as panes, but herdr's sidebar agent section intentionally focuses on detected agents rather than listing every shell.
@@ -141,15 +140,15 @@ herdr wait output 1-3 --match "server.*ready" --regex --timeout 30000
if it times out, exit code is `1`.
-## wait for an agent state
+## wait for an agent status
-block until another agent reaches a specific state:
+block until another agent reaches a specific status:
```bash
-herdr wait agent-state 1-1 --state idle --timeout 60000
+herdr wait agent-status 1-1 --status done --timeout 60000
```
-useful when you need another agent to finish before you proceed.
+use this when you want the same `done` / `idle` distinction the UI shows.
## send text or keys to a pane
@@ -264,13 +263,13 @@ herdr pane run 1-3 "review the test coverage in src/api/"
### coordinate with another agent
```bash
-herdr wait agent-state 1-1 --state idle --timeout 120000
+herdr wait agent-status 1-1 --status done --timeout 120000
herdr pane read 1-1 --source recent --lines 100
```
## notes
-- `workspace list`, `workspace create`, `tab list`, `tab create`, `tab get`, `tab focus`, `tab rename`, `tab close`, `pane list`, `pane get`, `pane split`, `wait output`, and `wait agent-state` print json on success.
+- `workspace list`, `workspace create`, `tab list`, `tab create`, `tab get`, `tab focus`, `tab rename`, `tab close`, `pane list`, `pane get`, `pane split`, `wait output`, and `wait agent-status` print json on success.
- `pane read` prints text, not json.
- `pane read --source recent-unwrapped` is useful when you want to inspect the same unwrapped transcript that `wait output --source recent` matches against.
- `pane send-text`, `pane send-keys`, and `pane run` print nothing on success.
diff --git a/SOCKET_API.md b/SOCKET_API.md
index 3329e0b4..c73e0a7d 100644
--- a/SOCKET_API.md
+++ b/SOCKET_API.md
@@ -14,7 +14,7 @@ there are three practical ways to integrate with herdr:
these layers are intentionally stacked on top of the same control surface.
-important difference: `pane.run` and `wait agent-state` are **cli conveniences**, not raw socket methods.
+important difference: `pane.run` and `wait agent-status` are **cli conveniences**, not raw socket methods.
## transport
@@ -109,7 +109,7 @@ for backward compatibility, requests also accept the older positional forms like
"pane_count": 1,
"tab_count": 1,
"active_tab_id": "w64e95948145ed1:1",
- "agent_state": "unknown"
+ "agent_status": "unknown"
}
```
@@ -123,7 +123,7 @@ for backward compatibility, requests also accept the older positional forms like
"label": "1",
"focused": true,
"pane_count": 1,
- "agent_state": "unknown"
+ "agent_status": "unknown"
}
```
@@ -137,11 +137,10 @@ for backward compatibility, requests also accept the older positional forms like
"focused": true,
"cwd": "/home/can/Projects/herdr",
"agent": "pi",
- "agent_state": "working",
+ "agent_status": "working",
"revision": 0
}
```
-
`pane_read` responses contain objects like:
```json
@@ -156,13 +155,16 @@ for backward compatibility, requests also accept the older positional forms like
}
```
-agent states are:
+`agent_status` is the public agent field:
- `idle`
- `working`
- `blocked`
+- `done`
- `unknown`
+`done` means the agent has finished, but you have not looked at that finished pane yet.
+
## methods at a glance
| method | purpose | success result type |
@@ -252,7 +254,7 @@ example response:
"pane_count": 1,
"tab_count": 1,
"active_tab_id": "1:1",
- "agent_state": "unknown"
+ "agent_status": "unknown"
}
}
}
@@ -662,7 +664,7 @@ base lifecycle subscriptions:
parameterized subscriptions:
- `pane.output_matched`
-- `pane.agent_state_changed`
+- `pane.agent_status_changed`
### event naming rule
@@ -717,13 +719,13 @@ example pushed event:
"pane_count": 1,
"tab_count": 1,
"active_tab_id": "1:1",
- "agent_state": "unknown"
+ "agent_status": "unknown"
}
}
}
```
-### example: subscribe to output matches and agent state changes
+### example: subscribe to output matches and agent status changes
request:
@@ -741,9 +743,9 @@ request:
"match": { "type": "substring", "value": "ready" }
},
{
- "type": "pane.agent_state_changed",
+ "type": "pane.agent_status_changed",
"pane_id": "1-1",
- "state": "idle"
+ "agent_status": "done"
}
]
}
@@ -753,7 +755,7 @@ request:
notes:
- `pane.output_matched` supports `source`, optional `lines`, matcher config, and optional `strip_ansi`
-- `pane.agent_state_changed` accepts an optional `state` filter; if omitted, any state transition for that pane can match
+- `pane.agent_status_changed` accepts an optional `agent_status` filter; if omitted, any status transition for that pane can match
example pushed `pane.output_matched` event:
@@ -776,20 +778,19 @@ example pushed `pane.output_matched` event:
}
```
-example pushed `pane.agent_state_changed` event:
+example pushed `pane.agent_status_changed` event:
```json
{
- "event": "pane.agent_state_changed",
+ "event": "pane.agent_status_changed",
"data": {
"pane_id": "1-1",
"workspace_id": "1",
- "state": "idle",
+ "agent_status": "done",
"agent": "pi"
}
}
```
-
## cli wrappers
these commands talk to the same local socket surface and are usually the easiest starting point for shell scripts and coding agents.
@@ -835,7 +836,7 @@ wait commands:
```text
herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex] [--raw]
-herdr wait agent-state <pane_id> --state <idle|working|blocked|unknown> [--timeout MS]
+herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS]
```
### cli behavior notes
@@ -848,7 +849,8 @@ herdr wait agent-state <pane_id> --state <idle|working|blocked|unknown> [--timeo
- `pane send-text`, `pane send-keys`, and `pane run` print nothing on success
- list/get/create/split/wait commands print json on success
- `pane run` is a convenience wrapper for `pane.send_input` with the command text followed by a real `Enter` keypress
-- `wait agent-state` is a cli convenience built on top of event subscriptions
+- `wait agent-status` is a cli convenience built on top of event subscriptions
+- use it when you want the same `done` / `idle` distinction the UI shows
- `--raw` disables ansi stripping for `pane read` and `wait output`
- `wait output --source recent` matches against unwrapped recent terminal text by default, so pane width and soft wrapping do not break matches
@@ -863,10 +865,10 @@ herdr pane run 1-2 "npm run dev"
herdr wait output 1-2 --match "ready" --timeout 30000
```
-wait for another agent to finish:
+wait for another agent to finish in the same user-facing sense the UI shows:
```bash
-herdr wait agent-state 1-1 --state idle --timeout 60000
+herdr wait agent-status 1-1 --status done --timeout 60000
```
inspect another pane's output:
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 28a80602..1b81e09c 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -15,7 +15,7 @@ use regex::Regex;
use tokio::sync::mpsc;
use crate::api::schema::{
- ErrorBody, ErrorResponse, Method, PaneAgentStateChangedEvent, PaneOutputMatchedEvent, Request,
+ ErrorBody, ErrorResponse, Method, PaneAgentStatusChangedEvent, PaneOutputMatchedEvent, Request,
ResponseResult, Subscription, SubscriptionEventData, SubscriptionEventEnvelope,
SubscriptionEventKind, SuccessResponse,
};
@@ -553,10 +553,10 @@ struct ActiveOutputMatchedSubscription {
request_prefix: String,
}
-struct ActiveAgentStateChangedSubscription {
+struct ActiveAgentStatusChangedSubscription {
pane_id: String,
- state_filter: Option<crate::api::schema::PaneAgentState>,
- last_state: Option<crate::api::schema::PaneAgentState>,
+ status_filter: Option<crate::api::schema::AgentStatus>,
+ last_status: Option<crate::api::schema::AgentStatus>,
request_prefix: String,
}
@@ -568,7 +568,7 @@ struct ActiveEventSubscription {
enum ActiveSubscription {
Event(ActiveEventSubscription),
OutputMatched(ActiveOutputMatchedSubscription),
- AgentStateChanged(ActiveAgentStateChangedSubscription),
+ AgentStatusChanged(ActiveAgentStatusChangedSubscription),
}
impl ActiveSubscription {
@@ -674,18 +674,21 @@ impl ActiveSubscription {
request_prefix: format!("{request_id}:sub:{index}"),
}))
}
- Subscription::PaneAgentStateChanged { pane_id, state } => {
+ Subscription::PaneAgentStatusChanged {
+ pane_id,
+ agent_status,
+ } => {
let probe =
match pane_get(format!("{request_id}:sub:{index}:probe"), &pane_id, api_tx) {
Ok(probe) => probe,
Err(error) => return Err(error),
};
- Ok(Self::AgentStateChanged(
- ActiveAgentStateChangedSubscription {
+ Ok(Self::AgentStatusChanged(
+ ActiveAgentStatusChangedSubscription {
pane_id,
- state_filter: state,
- last_state: Some(probe.agent_state),
+ status_filter: agent_status,
+ last_status: Some(probe.agent_status),
request_prefix: format!("{request_id}:sub:{index}"),
},
))
@@ -703,7 +706,7 @@ impl ActiveSubscription {
Self::OutputMatched(subscription) => {
serde_json::to_value(subscription.poll(api_tx)?).ok()
}
- Self::AgentStateChanged(subscription) => {
+ Self::AgentStatusChanged(subscription) => {
serde_json::to_value(subscription.poll(api_tx)?).ok()
}
}
@@ -758,7 +761,7 @@ impl ActiveOutputMatchedSubscription {
}
}
-impl ActiveAgentStateChangedSubscription {
+impl ActiveAgentStatusChangedSubscription {
fn poll(&mut self, api_tx: &ApiRequestSender) -> Option<SubscriptionEventEnvelope> {
let pane = pane_get(
format!("{}:pane", self.request_prefix),
@@ -766,24 +769,24 @@ impl ActiveAgentStateChangedSubscription {
api_tx,
)
.ok()?;
- let current_state = pane.agent_state;
- let previous_state = self.last_state.replace(current_state);
- if previous_state.is_none() || previous_state == Some(current_state) {
+ let current_status = pane.agent_status;
+ let previous_status = self.last_status.replace(current_status);
+ if previous_status.is_none() || previous_status == Some(current_status) {
return None;
}
if self
- .state_filter
- .is_some_and(|wanted| wanted != current_state)
+ .status_filter
+ .is_some_and(|wanted| wanted != current_status)
{
return None;
}
Some(SubscriptionEventEnvelope {
- event: SubscriptionEventKind::PaneAgentStateChanged,
- data: SubscriptionEventData::PaneAgentStateChanged(PaneAgentStateChangedEvent {
+ event: SubscriptionEventKind::PaneAgentStatusChanged,
+ data: SubscriptionEventData::PaneAgentStatusChanged(PaneAgentStatusChangedEvent {
pane_id: pane.pane_id,
workspace_id: pane.workspace_id,
- state: current_state,
+ agent_status: current_status,
agent: pane.agent,
}),
})
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 063663fa..568a276b 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -253,11 +253,11 @@ pub enum Subscription {
#[serde(default = "default_true")]
strip_ansi: bool,
},
- #[serde(rename = "pane.agent_state_changed")]
- PaneAgentStateChanged {
+ #[serde(rename = "pane.agent_status_changed")]
+ PaneAgentStatusChanged {
pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
- state: Option<PaneAgentState>,
+ agent_status: Option<AgentStatus>,
},
}
@@ -348,9 +348,9 @@ pub enum EventMatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
agent: Option<String>,
},
- PaneAgentStateChanged {
+ PaneAgentStatusChanged {
pane_id: String,
- state: PaneAgentState,
+ agent_status: AgentStatus,
},
}
@@ -371,7 +371,7 @@ pub enum EventKind {
PaneOutputChanged,
PaneExited,
PaneAgentDetected,
- PaneAgentStateChanged,
+ PaneAgentStatusChanged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -441,7 +441,7 @@ pub struct WorkspaceInfo {
pub pane_count: usize,
pub tab_count: usize,
pub active_tab_id: String,
- pub agent_state: PaneAgentState,
+ pub agent_status: AgentStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -452,7 +452,7 @@ pub struct TabInfo {
pub label: String,
pub focused: bool,
pub pane_count: usize,
- pub agent_state: PaneAgentState,
+ pub agent_status: AgentStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -465,7 +465,7 @@ pub struct PaneInfo {
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
- pub agent_state: PaneAgentState,
+ pub agent_status: AgentStatus,
pub revision: u64,
}
@@ -490,8 +490,8 @@ pub struct EventEnvelope {
pub enum SubscriptionEventKind {
#[serde(rename = "pane.output_matched")]
PaneOutputMatched,
- #[serde(rename = "pane.agent_state_changed")]
- PaneAgentStateChanged,
+ #[serde(rename = "pane.agent_status_changed")]
+ PaneAgentStatusChanged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -504,7 +504,7 @@ pub struct SubscriptionEventEnvelope {
#[serde(untagged)]
pub enum SubscriptionEventData {
PaneOutputMatched(PaneOutputMatchedEvent),
- PaneAgentStateChanged(PaneAgentStateChangedEvent),
+ PaneAgentStatusChanged(PaneAgentStatusChangedEvent),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -515,10 +515,10 @@ pub struct PaneOutputMatchedEvent {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PaneAgentStateChangedEvent {
+pub struct PaneAgentStatusChangedEvent {
pub pane_id: String,
pub workspace_id: String,
- pub state: PaneAgentState,
+ pub agent_status: AgentStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
}
@@ -581,10 +581,10 @@ pub enum EventData {
#[serde(default, skip_serializing_if = "Option::is_none")]
agent: Option<String>,
},
- PaneAgentStateChanged {
+ PaneAgentStatusChanged {
pane_id: String,
workspace_id: String,
- state: PaneAgentState,
+ agent_status: AgentStatus,
},
}
@@ -597,6 +597,16 @@ pub enum PaneAgentState {
Unknown,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentStatus {
+ Idle,
+ Working,
+ Blocked,
+ Done,
+ Unknown,
+}
+
fn default_true() -> bool {
true
}
@@ -777,9 +787,9 @@ mod tests {
"match": { "type": "substring", "value": "auth: received" }
},
{
- "type": "pane.agent_state_changed",
+ "type": "pane.agent_status_changed",
"pane_id": "p_1_1",
- "state": "blocked"
+ "agent_status": "done"
}
]
}
@@ -803,9 +813,9 @@ mod tests {
));
assert!(matches!(
&params.subscriptions[1],
- Subscription::PaneAgentStateChanged {
+ Subscription::PaneAgentStatusChanged {
pane_id,
- state: Some(PaneAgentState::Blocked),
+ agent_status: Some(AgentStatus::Done),
} if pane_id == "p_1_1"
));
}
@@ -872,9 +882,9 @@ mod tests {
"method": "events.wait",
"params": {
"match_event": {
- "event": "pane_agent_state_changed",
+ "event": "pane_agent_status_changed",
"pane_id": "p_1",
- "state": "blocked"
+ "agent_status": "done"
},
"timeout_ms": 30000
}
@@ -887,9 +897,9 @@ mod tests {
};
assert_eq!(
params.match_event,
- EventMatch::PaneAgentStateChanged {
+ EventMatch::PaneAgentStatusChanged {
pane_id: "p_1".into(),
- state: PaneAgentState::Blocked,
+ agent_status: AgentStatus::Done,
}
);
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 22e4eef6..87af63e5 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -827,12 +827,19 @@ impl App {
}
if update.previous_state != update.state {
+ let agent_status = self
+ .state
+ .workspaces
+ .get(update.ws_idx)
+ .and_then(|ws| ws.pane_state(update.pane_id))
+ .map(|pane| pane_agent_status(pane.state, pane.seen))
+ .unwrap_or_else(|| pane_agent_status(update.state, true));
self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneAgentStateChanged,
- data: crate::api::schema::EventData::PaneAgentStateChanged {
+ event: crate::api::schema::EventKind::PaneAgentStatusChanged,
+ data: crate::api::schema::EventData::PaneAgentStatusChanged {
pane_id,
workspace_id,
- state: pane_agent_state(update.state),
+ agent_status,
},
});
}
@@ -2200,7 +2207,7 @@ impl App {
fn tab_info(&self, ws_idx: usize, tab_idx: usize) -> Option<crate::api::schema::TabInfo> {
let ws = self.state.workspaces.get(ws_idx)?;
let tab = ws.tabs.get(tab_idx)?;
- let (agg_state, _) = tab
+ let (agg_state, seen) = tab
.panes
.values()
.map(|pane| (pane.state, pane.seen))
@@ -2213,7 +2220,7 @@ impl App {
label: tab.display_name(),
focused: self.state.active == Some(ws_idx) && ws.active_tab == tab_idx,
pane_count: tab.panes.len(),
- agent_state: pane_agent_state(agg_state),
+ agent_status: pane_agent_status(agg_state, seen),
})
}
@@ -2240,7 +2247,7 @@ impl App {
.and_then(|rt| rt.cwd())
.map(|cwd| cwd.display().to_string()),
agent: pane.detected_agent.map(agent_name),
- agent_state: pane_agent_state(pane.state),
+ agent_status: pane_agent_status(pane.state, pane.seen),
revision: 0,
})
}
@@ -2266,7 +2273,7 @@ impl App {
fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo {
let ws = &self.state.workspaces[index];
- let (agg_state, _) = ws.aggregate_state();
+ let (agg_state, seen) = ws.aggregate_state();
crate::api::schema::WorkspaceInfo {
workspace_id: self.public_workspace_id(index),
number: index + 1,
@@ -2277,7 +2284,7 @@ impl App {
active_tab_id: self
.public_tab_id(index, ws.active_tab)
.unwrap_or_else(|| format!("{}:{}", ws.id, ws.active_tab + 1)),
- agent_state: pane_agent_state(agg_state),
+ agent_status: pane_agent_status(agg_state, seen),
}
}
}
@@ -2349,12 +2356,16 @@ fn detect_state_from_api(state: crate::api::schema::PaneAgentState) -> crate::de
}
}
-fn pane_agent_state(state: crate::detect::AgentState) -> crate::api::schema::PaneAgentState {
- match state {
- crate::detect::AgentState::Idle => crate::api::schema::PaneAgentState::Idle,
- crate::detect::AgentState::Working => crate::api::schema::PaneAgentState::Working,
- crate::detect::AgentState::Blocked => crate::api::schema::PaneAgentState::Blocked,
- crate::detect::AgentState::Unknown => crate::api::schema::PaneAgentState::Unknown,
+fn pane_agent_status(
+ state: crate::detect::AgentState,
+ seen: bool,
+) -> crate::api::schema::AgentStatus {
+ match (state, seen) {
+ (crate::detect::AgentState::Idle, false) => crate::api::schema::AgentStatus::Done,
+ (crate::detect::AgentState::Idle, true) => crate::api::schema::AgentStatus::Idle,
+ (crate::detect::AgentState::Working, _) => crate::api::schema::AgentStatus::Working,
+ (crate::detect::AgentState::Blocked, _) => crate::api::schema::AgentStatus::Blocked,
+ (crate::detect::AgentState::Unknown, _) => crate::api::schema::AgentStatus::Unknown,
}
}
diff --git a/src/cli.rs b/src/cli.rs
index 88ca5abd..8f3d3e5b 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,7 +6,7 @@ use serde::Serialize;
use crate::api;
use crate::api::schema::{
- EmptyParams, Method, OutputMatch, PaneAgentState, PaneListParams, PaneReadParams,
+ AgentStatus, EmptyParams, Method, OutputMatch, PaneListParams, PaneReadParams,
PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget,
PaneWaitForOutputParams, ReadSource, Request, SplitDirection, Subscription, TabCreateParams,
TabListParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams,
@@ -105,7 +105,7 @@ fn run_wait_command(args: &[String]) -> std::io::Result<i32> {
match subcommand {
"output" => wait_output(&args[1..]),
- "agent-state" => wait_agent_state(&args[1..]),
+ "agent-status" => wait_agent_status(&args[1..]),
_ => {
print_wait_help();
Ok(2)
@@ -851,25 +851,25 @@ fn wait_output(args: &[String]) -> std::io::Result<i32> {
Ok(0)
}
-fn wait_agent_state(args: &[String]) -> std::io::Result<i32> {
+fn wait_agent_status(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
- eprintln!("usage: herdr wait agent-state <pane_id> --state <idle|working|blocked|unknown> [--timeout MS]");
+ eprintln!("usage: herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS]");
return Ok(2);
};
let pane_id = normalize_pane_id(raw_pane_id);
let mut timeout_ms = None;
- let mut desired_state = None;
+ let mut desired_status = None;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
- "--state" => {
+ "--status" => {
let Some(value) = args.get(index + 1) else {
- eprintln!("missing value for --state");
+ eprintln!("missing value for --status");
return Ok(2);
};
- desired_state = Some(parse_agent_state(value)?);
+ desired_status = Some(parse_agent_status(value)?);
index += 2;
}
"--timeout" => {
@@ -887,21 +887,31 @@ fn wait_agent_state(args: &[String]) -> std::io::Result<i32> {
}
}
- let Some(state) = desired_state else {
- eprintln!("missing required --state");
+ let Some(agent_status) = desired_status else {
+ eprintln!("missing required --status");
return Ok(2);
};
- let request = Request {
- id: "cli:wait:agent-state".into(),
- method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams {
- subscriptions: vec![Subscription::PaneAgentStateChanged {
- pane_id,
- state: Some(state),
- }],
- }),
- };
+ wait_for_agent_change(
+ Request {
+ id: "cli:wait:agent-status".into(),
+ method: Method::EventsSubscribe(crate::api::schema::EventsSubscribeParams {
+ subscriptions: vec![Subscription::PaneAgentStatusChanged {
+ pane_id,
+ agent_status: Some(agent_status),
+ }],
+ }),
+ },
+ timeout_ms,
+ "timed out waiting for agent status change",
+ )
+}
+fn wait_for_agent_change(
+ request: Request,
+ timeout_ms: Option<u64>,
+ timeout_message: &str,
+) -> std::io::Result<i32> {
let mut stream = UnixStream::connect(api::socket_path())?;
stream.write_all(serde_json::to_string(&request)?.as_bytes())?;
stream.write_all(b"\n")?;
@@ -941,7 +951,7 @@ fn wait_agent_state(args: &[String]) -> std::io::Result<i32> {
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
) =>
{
- eprintln!("timed out waiting for agent state change");
+ eprintln!("{timeout_message}");
Ok(1)
}
Err(err) => Err(err),
@@ -1017,14 +1027,15 @@ fn parse_read_source(value: &str) -> std::io::Result<ReadSource> {
}
}
-fn parse_agent_state(value: &str) -> std::io::Result<PaneAgentState> {
+fn parse_agent_status(value: &str) -> std::io::Result<AgentStatus> {
match value {
- "idle" => Ok(PaneAgentState::Idle),
- "working" => Ok(PaneAgentState::Working),
- "blocked" => Ok(PaneAgentState::Blocked),
- "unknown" => Ok(PaneAgentState::Unknown),
+ "idle" => Ok(AgentStatus::Idle),
+ "working" => Ok(AgentStatus::Working),
+ "blocked" => Ok(AgentStatus::Blocked),
+ "done" => Ok(AgentStatus::Done),
+ "unknown" => Ok(AgentStatus::Unknown),
_ => Err(std::io::Error::other(format!(
- "invalid agent state: {value} (expected idle, working, blocked, or unknown)"
+ "invalid agent status: {value} (expected idle, working, blocked, done, or unknown)"
))),
}
}
@@ -1077,7 +1088,7 @@ fn print_wait_help() {
eprintln!("herdr wait commands:");
eprintln!(" herdr wait output <pane_id> --match <text> [--source visible|recent|recent-unwrapped] [--lines N] [--timeout MS] [--regex]");
eprintln!(
- " herdr wait agent-state <pane_id> --state <idle|working|blocked|unknown> [--timeout MS]"
+ " herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [--timeout MS]"
);
}
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index a7e61fa6..d5fa190c 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -866,7 +866,7 @@ fn pane_report_agent_updates_effective_state() {
),
);
assert_eq!(pane["result"]["pane"]["agent"], "pi");
- assert_eq!(pane["result"]["pane"]["agent_state"], "working");
+ assert_eq!(pane["result"]["pane"]["agent_status"], "working");
cleanup_spawned_herdr(child, base);
}
@@ -989,7 +989,7 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
pane["result"]["pane"]["agent"].is_null(),
"pane reacquired pi during graceful release: {pane}"
);
- assert_eq!(pane["result"]["pane"]["agent_state"], "unknown");
+ assert_eq!(pane["result"]["pane"]["agent_status"], "unknown");
thread::sleep(Duration::from_millis(50));
}
@@ -1005,7 +1005,7 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
),
);
if pane["result"]["pane"]["agent"].is_null()
- && pane["result"]["pane"]["agent_state"] == "unknown"
+ && pane["result"]["pane"]["agent_status"] == "unknown"
{
break;
}
@@ -1105,7 +1105,7 @@ fn pane_clear_agent_authority_restores_fallback_state() {
pane_id
),
);
- let fallback_state = fallback_before_hook["result"]["pane"]["agent_state"]
+ let fallback_status = fallback_before_hook["result"]["pane"]["agent_status"]
.as_str()
.unwrap()
.to_string();
@@ -1136,13 +1136,13 @@ fn pane_clear_agent_authority_restores_fallback_state() {
),
);
assert_eq!(pane["result"]["pane"]["agent"], "pi");
- assert_eq!(pane["result"]["pane"]["agent_state"], fallback_state);
+ assert_eq!(pane["result"]["pane"]["agent_status"], fallback_status);
cleanup_spawned_herdr(child, base);
}
#[test]
-fn events_subscribe_streams_output_and_agent_state_events() {
+fn events_subscribe_streams_output_and_agent_status_events() {
let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
@@ -1196,7 +1196,7 @@ fn events_subscribe_streams_output_and_agent_state_events() {
let mut reader = open_subscription(
&socket_path,
&format!(
- r#"{{"id":"sub_1","method":"events.subscribe","params":{{"subscriptions":[{{"type":"pane.output_matched","pane_id":"{}","source":"recent","lines":40,"match":{{"type":"substring","value":"hello from socket"}}}},{{"type":"pane.agent_state_changed","pane_id":"{}","state":"idle"}}]}}}}"#,
+ r#"{{"id":"sub_1","method":"events.subscribe","params":{{"subscriptions":[{{"type":"pane.output_matched","pane_id":"{}","source":"recent","lines":40,"match":{{"type":"substring","value":"hello from socket"}}}},{{"type":"pane.agent_status_changed","pane_id":"{}","agent_status":"idle"}}]}}}}"#,
pane_id, pane_id,
),
);
@@ -1252,10 +1252,134 @@ fn events_subscribe_streams_output_and_agent_state_events() {
assert_eq!(send_enter["result"]["type"], "ok");
let agent_idle = reader.read_json_line(Duration::from_secs(8));
- assert_eq!(agent_idle["event"], "pane.agent_state_changed");
+ assert_eq!(agent_idle["event"], "pane.agent_status_changed");
assert_eq!(agent_idle["data"]["pane_id"], pane_id);
- assert_eq!(agent_idle["data"]["state"], "idle");
+ assert_eq!(agent_idle["data"]["agent_status"], "idle");
assert_eq!(agent_idle["data"]["agent"], "pi");
cleanup_spawned_herdr(child, base);
}
+
+#[test]
+fn pane_info_and_subscriptions_expose_done_agent_status() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let bin_dir = base.join("bin");
+
+ fs::create_dir_all(&bin_dir).unwrap();
+ let fake_pi = bin_dir.join("pi");
+ fs::write(
+ &fake_pi,
+ "#!/bin/sh\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
+ )
+ .unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(&fake_pi).unwrap().permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(&fake_pi, perms).unwrap();
+ }
+
+ let inherited_path = std::env::var("PATH").unwrap_or_default();
+ let path_override = format!("{}:{}", bin_dir.display(), inherited_path);
+ let child = spawn_herdr_with_path(
+ &config_home,
+ &runtime_dir,
+ &socket_path,
+ Some(Path::new(&path_override)),
+ );
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_status_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let background_pane_id = format!("{}-1", workspace_id);
+
+ let tab_created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_status_2","method":"tab.create","params":{{"workspace_id":"{}","focus":true}}}}"#,
+ workspace_id
+ ),
+ );
+ assert_eq!(tab_created["result"]["type"], "tab_info");
+
+ let mut reader = open_subscription(
+ &socket_path,
+ &format!(
+ r#"{{"id":"sub_status","method":"events.subscribe","params":{{"subscriptions":[{{"type":"pane.agent_status_changed","pane_id":"{}","agent_status":"done"}}]}}}}"#,
+ background_pane_id,
+ ),
+ );
+ let ack = reader.read_json_line(Duration::from_secs(2));
+ assert_eq!(ack["id"], "sub_status");
+ assert_eq!(ack["result"]["type"], "subscription_started");
+
+ let send_pi = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_status_3","method":"pane.send_text","params":{{"pane_id":"{}","text":"pi"}}}}"#,
+ background_pane_id
+ ),
+ );
+ assert_eq!(send_pi["result"]["type"], "ok");
+ let send_enter = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_status_4","method":"pane.send_keys","params":{{"pane_id":"{}","keys":["Enter"]}}}}"#,
+ background_pane_id
+ ),
+ );
+ assert_eq!(send_enter["result"]["type"], "ok");
+
+ let status_event = reader.read_json_line(Duration::from_secs(8));
+ assert_eq!(status_event["event"], "pane.agent_status_changed");
+ assert_eq!(status_event["data"]["pane_id"], background_pane_id);
+ assert_eq!(status_event["data"]["agent_status"], "done");
+ assert_eq!(status_event["data"]["agent"], "pi");
+
+ let pane = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_status_5","method":"pane.get","params":{{"pane_id":"{}"}}}}"#,
+ background_pane_id
+ ),
+ );
+ assert_eq!(pane["result"]["pane"]["agent_status"], "done");
+
+ let focused_tab_id = created["result"]["workspace"]["active_tab_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let tab_focus = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_status_6","method":"tab.focus","params":{{"tab_id":"{}"}}}}"#,
+ focused_tab_id
+ ),
+ );
+ assert_eq!(tab_focus["result"]["type"], "tab_info");
+
+ let pane_after_focus = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_status_7","method":"pane.get","params":{{"pane_id":"{}"}}}}"#,
+ background_pane_id
+ ),
+ );
+ assert_eq!(pane_after_focus["result"]["pane"]["agent_status"], "idle");
+
+ cleanup_spawned_herdr(child, base);
+}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 3e9b9f31..1395008b 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -776,7 +776,7 @@ fn pane_shell_gets_herdr_socket_and_pane_env() {
}
#[test]
-fn wait_agent_state_exits_when_state_matches() {
+fn wait_agent_status_exits_when_idle_status_matches() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
@@ -851,9 +851,9 @@ fn wait_agent_state_exits_when_state_matches() {
&socket_path,
&[
"wait",
- "agent-state",
+ "agent-status",
"1-1",
- "--state",
+ "--status",
"idle",
"--timeout",
"5000",
@@ -865,8 +865,117 @@ fn wait_agent_state_exits_when_state_matches() {
String::from_utf8_lossy(&waited.stderr)
);
let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
- assert_eq!(waited_json["event"], "pane.agent_state_changed");
- assert_eq!(waited_json["data"]["state"], "idle");
+ assert_eq!(waited_json["event"], "pane.agent_status_changed");
+ assert_eq!(waited_json["data"]["agent_status"], "idle");
+ assert_eq!(waited_json["data"]["agent"], "pi");
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
+fn wait_agent_status_exits_when_done_status_matches() {
+ let base = unique_test_dir();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+ let bin_dir = base.join("bin");
+
+ fs::create_dir_all(&bin_dir).unwrap();
+ let fake_pi = bin_dir.join("pi");
+ fs::write(
+ &fake_pi,
+ "#!/bin/sh\nprintf 'Working...\\n'\nsleep 1\nprintf '\\033[2J\\033[Hdone\\n'\n",
+ )
+ .unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(&fake_pi).unwrap().permissions();
+ perms.set_mode(0o755);
+ fs::set_permissions(&fake_pi, perms).unwrap();
+ }
+
+ let pair = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .unwrap();
+ fs::create_dir_all(config_home.join("herdr")).unwrap();
+ fs::create_dir_all(&runtime_dir).unwrap();
+ fs::write(
+ config_home.join("herdr/config.toml"),
+ "onboarding = false\n",
+ )
+ .unwrap();
+ let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr"));
+ cmd.arg("--no-session");
+ cmd.env("XDG_CONFIG_HOME", &config_home);
+ cmd.env("XDG_RUNTIME_DIR", &runtime_dir);
+ cmd.env("HERDR_SOCKET_PATH", &socket_path);
+ cmd.env_remove("HERDR_ENV");
+ cmd.env(
+ "PATH",
+ format!(
+ "{}:{}",
+ bin_dir.display(),
+ std::env::var("PATH").unwrap_or_default()
+ ),
+ );
+ let child = pair.slave.spawn_command(cmd).unwrap();
+ let herdr = SpawnedHerdr {
+ _master: pair.master,
+ child,
+ };
+
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_status_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let tab_created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_cli_status_2","method":"tab.create","params":{{"workspace_id":"{}","focus":true}}}}"#,
+ workspace_id
+ ),
+ );
+ assert_eq!(tab_created["result"]["type"], "tab_info");
+
+ let start_pi = run_cli(&socket_path, &["pane", "run", "1-1", "pi"]);
+ assert!(start_pi.status.success());
+
+ let waited = run_cli(
+ &socket_path,
+ &[
+ "wait",
+ "agent-status",
+ "1-1",
+ "--status",
+ "done",
+ "--timeout",
+ "5000",
+ ],
+ );
+ assert!(
+ waited.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&waited.stderr)
+ );
+ let waited_json: serde_json::Value = serde_json::from_slice(&waited.stdout).unwrap();
+ assert_eq!(waited_json["event"], "pane.agent_status_changed");
+ assert_eq!(waited_json["data"]["agent_status"], "done");
assert_eq!(waited_json["data"]["agent"], "pi");
cleanup_spawned_herdr(herdr, base);