aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 01:41:16 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 01:41:16 +0300
commit86adf88351e17c6a11cac253714b169eac0db233 (patch)
tree883d32b0126e0b69449e0e9c0a7017d279c23935
parentd2aa8865911ded97a67054fb2392a1c12fae018c (diff)
feat: add terminal identity and agent cli facade
-rw-r--r--SOCKET_API.md46
-rw-r--r--src/api/mod.rs8
-rw-r--r--src/api/schema.rs67
-rw-r--r--src/app/agents.rs213
-rw-r--r--src/app/api.rs147
-rw-r--r--src/app/creation.rs1
-rw-r--r--src/app/mod.rs101
-rw-r--r--src/app/terminal_targets.rs165
-rw-r--r--src/cli.rs198
-rw-r--r--src/main.rs6
-rw-r--r--src/persist/restore.rs3
-rw-r--r--src/terminal/id.rs29
-rw-r--r--src/terminal/mod.rs3
-rw-r--r--src/workspace.rs12
-rw-r--r--src/workspace/tab.rs12
-rw-r--r--tests/api_ping.rs169
-rw-r--r--tests/cli_wrapper.rs57
17 files changed, 1227 insertions, 10 deletions
diff --git a/SOCKET_API.md b/SOCKET_API.md
index 51046860..fe6aed2d 100644
--- a/SOCKET_API.md
+++ b/SOCKET_API.md
@@ -9,7 +9,7 @@ if you are teaching an agent that is already running inside herdr, start with [`
there are three practical ways to integrate with herdr:
- **agent skill** — [`SKILL.md`](./SKILL.md). best when an agent inside herdr just needs to learn the workflow quickly.
-- **cli wrappers** — `herdr server stop`, `herdr workspace ...`, `herdr tab ...`, `herdr pane ...`, `herdr wait ...`. best for shell scripts and simple orchestration.
+- **cli wrappers** — `herdr server stop`, `herdr workspace ...`, `herdr tab ...`, `herdr agent ...`, `herdr pane ...`, `herdr wait ...`. best for shell scripts and simple orchestration.
- **raw socket api** — best when you want direct request/response control or long-lived event subscriptions.
these layers are intentionally stacked on top of the same control surface.
@@ -141,6 +141,7 @@ for backward compatibility, requests also accept the older positional forms like
```json
{
"pane_id": "w64e95948145ed1-1",
+ "terminal_id": "term_64e95948145ed1",
"workspace_id": "w64e95948145ed1",
"tab_id": "w64e95948145ed1:1",
"focused": true,
@@ -152,8 +153,29 @@ for backward compatibility, requests also accept the older positional forms like
}
```
+`terminal_id` is an opaque terminal identity. during the pane-backed transition each pane has one terminal id, but clients should not derive it from the pane id.
+
`label` is an optional manual pane name set through `pane.rename`.
+`agent_info` responses contain a terminal-facing view of the same live pane-backed terminal:
+
+```json
+{
+ "terminal_id": "term_64e95948145ed1",
+ "name": "reviewer",
+ "agent": "pi",
+ "agent_status": "working",
+ "workspace_id": "w64e95948145ed1",
+ "tab_id": "w64e95948145ed1:1",
+ "pane_id": "w64e95948145ed1-1",
+ "focused": true,
+ "cwd": "/home/can/Projects/herdr",
+ "revision": 0
+}
+```
+
+`name` is the manual pane label treated as the terminal/agent name by `agent.*` commands. `agent.rename` rejects duplicate active names; `pane.rename` remains backward-compatible and can still create duplicates.
+
`agent` is an optional display label string.
- when herdr detects a built-in agent, this is that built-in name like `pi` or `claude`
@@ -203,6 +225,12 @@ for backward compatibility, requests also accept the older positional forms like
| `tab.focus` | focus a tab | `tab_info` |
| `tab.rename` | rename a tab | `tab_info` |
| `tab.close` | close a tab | `ok` |
+| `agent.list` | list named or detected terminal-backed agents | `agent_list` |
+| `agent.get` | inspect one terminal-backed agent by terminal id, unique name, or pane id | `agent_info` |
+| `agent.read` | read output from one terminal-backed agent | `pane_read` |
+| `agent.send` | send literal text to one terminal-backed agent | `ok` |
+| `agent.rename` | set or clear the unique agent/terminal name | `agent_info` |
+| `agent.focus` | show the terminal-backed agent in the TUI | `agent_info` |
| `pane.list` | list panes, optionally filtered by workspace | `pane_list` |
| `pane.get` | inspect one pane | `pane_info` |
| `pane.rename` | set or clear a manual pane label | `pane_info` |
@@ -960,6 +988,19 @@ herdr tab rename <tab_id> <label>
herdr tab close <tab_id>
```
+agent commands:
+
+```text
+herdr agent list
+herdr agent get <target>
+herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]
+herdr agent send <target> <text>
+herdr agent rename <target> <name>|--clear
+herdr agent focus <target>
+```
+
+agent targets accept terminal ids, unique agent names, and legacy pane ids. duplicate names fail clearly for `agent.*`; `pane.rename` can still create duplicate pane labels for backward compatibility.
+
pane commands:
```text
@@ -994,6 +1035,9 @@ herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [
- `tab create` without `--label` keeps the default numbered tab naming
- `tab create --label` applies the custom tab name immediately
- `tab create` returns `result.tab` and `result.root_pane`
+- `agent list` shows named panes and detected/reported agents as terminal-backed agents
+- `agent focus` switches the TUI workspace/tab/pane focus; it is not direct terminal attach
+- `agent rename` enforces unique active names; use `pane rename` only when duplicate labels are intentional
- `pane split` keeps focus where it is by default; pass `--focus` to switch to the new pane
- `pane read` prints **text**, not json
- `pane read --format ansi` and `pane read --ansi` print a rendered ANSI snapshot with colors/styles preserved
diff --git a/src/api/mod.rs b/src/api/mod.rs
index f125b156..9a4134ab 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -39,6 +39,8 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::TabFocus(_)
| Method::TabRename(_)
| Method::TabClose(_)
+ | Method::AgentRename(_)
+ | Method::AgentFocus(_)
| Method::PaneSplit(_)
| Method::PaneRename(_)
| Method::PaneReportAgent(_)
@@ -399,6 +401,12 @@ fn api_method_name(method: &Method) -> &'static str {
Method::TabFocus(_) => "tab.focus",
Method::TabRename(_) => "tab.rename",
Method::TabClose(_) => "tab.close",
+ Method::AgentList(_) => "agent.list",
+ Method::AgentGet(_) => "agent.get",
+ Method::AgentRead(_) => "agent.read",
+ Method::AgentSend(_) => "agent.send",
+ Method::AgentRename(_) => "agent.rename",
+ Method::AgentFocus(_) => "agent.focus",
Method::PaneSplit(_) => "pane.split",
Method::PaneList(_) => "pane.list",
Method::PaneGet(_) => "pane.get",
diff --git a/src/api/schema.rs b/src/api/schema.rs
index bed8415a..e6d24aaf 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -40,6 +40,18 @@ pub enum Method {
TabRename(TabRenameParams),
#[serde(rename = "tab.close")]
TabClose(TabTarget),
+ #[serde(rename = "agent.list")]
+ AgentList(EmptyParams),
+ #[serde(rename = "agent.get")]
+ AgentGet(AgentTarget),
+ #[serde(rename = "agent.read")]
+ AgentRead(AgentReadParams),
+ #[serde(rename = "agent.send")]
+ AgentSend(AgentSendParams),
+ #[serde(rename = "agent.rename")]
+ AgentRename(AgentRenameParams),
+ #[serde(rename = "agent.focus")]
+ AgentFocus(AgentTarget),
#[serde(rename = "pane.split")]
PaneSplit(PaneSplitParams),
#[serde(rename = "pane.list")]
@@ -138,6 +150,36 @@ pub struct TabRenameParams {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentTarget {
+ pub target: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentReadParams {
+ pub target: String,
+ pub source: ReadSource,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub lines: Option<u32>,
+ #[serde(default)]
+ pub format: ReadFormat,
+ #[serde(default = "default_true")]
+ pub strip_ansi: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentSendParams {
+ pub target: String,
+ pub text: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentRenameParams {
+ pub target: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub name: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneSplitParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
@@ -478,6 +520,12 @@ pub enum ResponseResult {
TabList {
tabs: Vec<TabInfo>,
},
+ AgentInfo {
+ agent: AgentInfo,
+ },
+ AgentList {
+ agents: Vec<AgentInfo>,
+ },
PaneInfo {
pane: PaneInfo,
},
@@ -536,8 +584,26 @@ pub struct TabInfo {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentInfo {
+ pub terminal_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub name: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub agent: Option<String>,
+ pub agent_status: AgentStatus,
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub pane_id: String,
+ pub focused: bool,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub cwd: Option<String>,
+ pub revision: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaneInfo {
pub pane_id: String,
+ pub terminal_id: String,
pub workspace_id: String,
pub tab_id: String,
pub focused: bool,
@@ -1028,6 +1094,7 @@ mod tests {
},
root_pane: PaneInfo {
pane_id: "w_1-3".into(),
+ terminal_id: "term_example".into(),
workspace_id: "w_1".into(),
tab_id: "w_1:2".into(),
focused: false,
diff --git a/src/app/agents.rs b/src/app/agents.rs
new file mode 100644
index 00000000..88f7ec97
--- /dev/null
+++ b/src/app/agents.rs
@@ -0,0 +1,213 @@
+use super::{terminal_targets::TerminalTargetError, App, Mode};
+
+impl App {
+ pub(super) fn collect_agent_infos(&self) -> Vec<crate::api::schema::AgentInfo> {
+ self.state
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ ws.tabs.iter().flat_map(move |tab| {
+ tab.layout
+ .pane_ids()
+ .into_iter()
+ .filter_map(move |pane_id| self.agent_info(ws_idx, pane_id))
+ })
+ })
+ .filter(|agent| agent.name.is_some() || agent.agent.is_some())
+ .collect()
+ }
+
+ pub(super) fn agent_info_for_target(
+ &self,
+ target: &str,
+ ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> {
+ let resolved = self.resolve_terminal_target(target)?;
+ self.agent_info(resolved.ws_idx, resolved.pane_id)
+ .ok_or_else(|| TerminalTargetError::NotFound {
+ target: target.to_string(),
+ })
+ }
+
+ pub(super) fn focus_agent_target(
+ &mut self,
+ target: &str,
+ ) -> Result<crate::api::schema::AgentInfo, TerminalTargetError> {
+ let resolved = self.resolve_terminal_target(target)?;
+ self.state.switch_workspace(resolved.ws_idx);
+ self.state.switch_tab(resolved.tab_idx);
+ if let Some(tab) = self
+ .state
+ .workspaces
+ .get_mut(resolved.ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(resolved.tab_idx))
+ {
+ tab.layout.focus_pane(resolved.pane_id);
+ }
+ self.state.mode = Mode::Terminal;
+ self.agent_info(resolved.ws_idx, resolved.pane_id)
+ .ok_or_else(|| TerminalTargetError::NotFound {
+ target: target.to_string(),
+ })
+ }
+
+ pub(super) fn rename_agent_target(
+ &mut self,
+ target: &str,
+ name: Option<String>,
+ ) -> Result<crate::api::schema::AgentInfo, AgentRenameError> {
+ let resolved = self
+ .resolve_terminal_target(target)
+ .map_err(AgentRenameError::Target)?;
+ let normalized_name = name.and_then(|name| {
+ let trimmed = name.trim().to_string();
+ (!trimmed.is_empty()).then_some(trimmed)
+ });
+
+ if let Some(name) = normalized_name.as_deref() {
+ let conflicts = self.agent_name_conflicts(name, &resolved.terminal_id);
+ if !conflicts.is_empty() {
+ return Err(AgentRenameError::DuplicateName {
+ name: name.to_string(),
+ candidates: conflicts,
+ });
+ }
+ }
+
+ let Some(ws) = self.state.workspaces.get_mut(resolved.ws_idx) else {
+ return Err(AgentRenameError::Target(TerminalTargetError::NotFound {
+ target: target.to_string(),
+ }));
+ };
+ let Some(pane_state) = ws.pane_state_mut(resolved.pane_id) else {
+ return Err(AgentRenameError::Target(TerminalTargetError::NotFound {
+ target: target.to_string(),
+ }));
+ };
+ match normalized_name {
+ Some(name) => pane_state.set_manual_label(name),
+ None => pane_state.clear_manual_label(),
+ }
+ self.state.mark_session_dirty();
+ self.agent_info(resolved.ws_idx, resolved.pane_id)
+ .ok_or_else(|| {
+ AgentRenameError::Target(TerminalTargetError::NotFound {
+ target: target.to_string(),
+ })
+ })
+ }
+
+ pub(super) fn agent_target_error_body(
+ &self,
+ err: TerminalTargetError,
+ ) -> crate::api::schema::ErrorBody {
+ match err {
+ TerminalTargetError::NotFound { target } => crate::api::schema::ErrorBody {
+ code: "agent_not_found".into(),
+ message: format!("agent target {target} not found"),
+ },
+ TerminalTargetError::Ambiguous { target, candidates } => {
+ crate::api::schema::ErrorBody {
+ code: "agent_target_ambiguous".into(),
+ message: format!(
+ "agent target {target} is ambiguous; candidates: {}",
+ candidates
+ .into_iter()
+ .map(|candidate| format!(
+ "terminal_id={} pane_id={} workspace_id={} tab_id={} cwd={} status={:?}",
+ candidate.terminal_id,
+ candidate.pane_id,
+ candidate.workspace_id,
+ candidate.tab_id,
+ candidate.cwd.unwrap_or_else(|| "unknown".into()),
+ candidate.agent_status,
+ ))
+ .collect::<Vec<_>>()
+ .join("; ")
+ ),
+ }
+ }
+ }
+ }
+
+ pub(super) fn agent_rename_error_body(
+ &self,
+ err: AgentRenameError,
+ ) -> crate::api::schema::ErrorBody {
+ match err {
+ AgentRenameError::Target(err) => self.agent_target_error_body(err),
+ AgentRenameError::DuplicateName { name, candidates } => crate::api::schema::ErrorBody {
+ code: "agent_name_taken".into(),
+ message: format!(
+ "agent name {name} is already used; candidates: {}",
+ candidates
+ .into_iter()
+ .map(|candidate| format!(
+ "terminal_id={} pane_id={} workspace_id={} tab_id={} cwd={} status={:?}",
+ candidate.terminal_id,
+ candidate.pane_id,
+ candidate.workspace_id,
+ candidate.tab_id,
+ candidate.cwd.unwrap_or_else(|| "unknown".into()),
+ candidate.agent_status,
+ ))
+ .collect::<Vec<_>>()
+ .join("; ")
+ ),
+ },
+ }
+ }
+
+ fn agent_info(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<crate::api::schema::AgentInfo> {
+ let pane = self.pane_info(ws_idx, pane_id)?;
+ Some(crate::api::schema::AgentInfo {
+ terminal_id: pane.terminal_id,
+ name: pane.label,
+ agent: pane.agent,
+ agent_status: pane.agent_status,
+ workspace_id: pane.workspace_id,
+ tab_id: pane.tab_id,
+ pane_id: pane.pane_id,
+ focused: pane.focused,
+ cwd: pane.cwd,
+ revision: pane.revision,
+ })
+ }
+
+ fn agent_name_conflicts(
+ &self,
+ name: &str,
+ except_terminal_id: &str,
+ ) -> Vec<crate::api::schema::AgentInfo> {
+ self.state
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ ws.tabs.iter().flat_map(move |tab| {
+ tab.layout
+ .pane_ids()
+ .into_iter()
+ .filter_map(move |pane_id| {
+ let pane_state = ws.pane_state(pane_id)?;
+ (pane_state.manual_label.as_deref() == Some(name))
+ .then(|| self.agent_info(ws_idx, pane_id))?
+ })
+ })
+ })
+ .filter(|agent| agent.terminal_id != except_terminal_id)
+ .collect()
+ }
+}
+
+pub(super) enum AgentRenameError {
+ Target(TerminalTargetError),
+ DuplicateName {
+ name: String,
+ candidates: Vec<crate::api::schema::AgentInfo>,
+ },
+}
diff --git a/src/app/api.rs b/src/app/api.rs
index 5f959365..666e8a20 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -828,6 +828,153 @@ impl App {
result: ResponseResult::Ok {},
}
}
+ Method::AgentList(_) => SuccessResponse {
+ id: request.id,
+ result: ResponseResult::AgentList {
+ agents: self.collect_agent_infos(),
+ },
+ },
+ Method::AgentGet(target) => {
+ let agent = match self.agent_info_for_target(&target.target) {
+ Ok(agent) => agent,
+ Err(err) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: self.agent_target_error_body(err),
+ })
+ .unwrap();
+ }
+ };
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::AgentInfo { agent },
+ }
+ }
+ Method::AgentFocus(target) => {
+ let agent = match self.focus_agent_target(&target.target) {
+ Ok(agent) => agent,
+ Err(err) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: self.agent_target_error_body(err),
+ })
+ .unwrap();
+ }
+ };
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::AgentInfo { agent },
+ }
+ }
+ Method::AgentRename(params) => {
+ let agent = match self.rename_agent_target(&params.target, params.name) {
+ Ok(agent) => agent,
+ Err(err) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: self.agent_rename_error_body(err),
+ })
+ .unwrap();
+ }
+ };
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::AgentInfo { agent },
+ }
+ }
+ Method::AgentRead(params) => {
+ let resolved = match self.resolve_terminal_target(&params.target) {
+ Ok(resolved) => resolved,
+ Err(err) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: self.agent_target_error_body(err),
+ })
+ .unwrap();
+ }
+ };
+ let Some((pane, workspace_id)) =
+ self.lookup_runtime(resolved.ws_idx, resolved.pane_id)
+ else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "agent_not_found".into(),
+ message: format!("agent target {} not found", params.target),
+ },
+ })
+ .unwrap();
+ };
+ let requested_lines = params.lines.unwrap_or(80).min(1000) as usize;
+ let text = match params.format {
+ ReadFormat::Text => match params.source {
+ ReadSource::Visible => pane.visible_text(),
+ ReadSource::Recent => pane.recent_text(requested_lines),
+ ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
+ },
+ ReadFormat::Ansi => match params.source {
+ ReadSource::Visible => pane.visible_ansi(),
+ ReadSource::Recent => pane.recent_ansi(requested_lines),
+ ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
+ },
+ };
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::PaneRead {
+ read: PaneReadResult {
+ pane_id: self
+ .public_pane_id(resolved.ws_idx, resolved.pane_id)
+ .unwrap_or_else(|| params.target.clone()),
+ workspace_id,
+ tab_id: self
+ .public_tab_id(resolved.ws_idx, resolved.tab_idx)
+ .unwrap(),
+ source: params.source,
+ format: params.format,
+ text,
+ revision: 0,
+ truncated: false,
+ },
+ },
+ }
+ }
+ Method::AgentSend(params) => {
+ let resolved = match self.resolve_terminal_target(&params.target) {
+ Ok(resolved) => resolved,
+ Err(err) => {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: self.agent_target_error_body(err),
+ })
+ .unwrap();
+ }
+ };
+ let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id)
+ else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "agent_not_found".into(),
+ message: format!("agent target {} not found", params.target),
+ },
+ })
+ .unwrap();
+ };
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "agent_send_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap();
+ }
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
Method::PaneSplit(params) => {
let Some((ws_idx, target_pane_id)) = self.parse_pane_id(&params.target_pane_id)
else {
diff --git a/src/app/creation.rs b/src/app/creation.rs
index a2758d7a..f1ee41d8 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -239,6 +239,7 @@ impl App {
.is_some_and(|focused| focused == pane_id);
Some(crate::api::schema::PaneInfo {
pane_id: self.public_pane_id(ws_idx, pane_id)?,
+ terminal_id: ws.terminal_id(pane_id)?.to_string(),
workspace_id: self.public_workspace_id(ws_idx),
tab_id: self.public_tab_id(ws_idx, tab_idx)?,
focused,
diff --git a/src/app/mod.rs b/src/app/mod.rs
index d9f666ed..d37bcb5d 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -5,6 +5,7 @@
//! - `input.rs` — key/mouse → action translation
pub(crate) mod actions;
+mod agents;
mod api;
mod api_helpers;
mod config_io;
@@ -14,6 +15,7 @@ mod input;
mod runtime;
mod session;
pub mod state;
+mod terminal_targets;
mod theme_sync;
use std::collections::{HashMap, HashSet};
@@ -1512,6 +1514,8 @@ mod tests {
assert_eq!(tab.workspace_id, workspace.workspace_id);
assert_eq!(root_pane.workspace_id, workspace.workspace_id);
assert_eq!(root_pane.tab_id, tab.tab_id);
+ assert!(root_pane.terminal_id.starts_with("term_"));
+ assert_ne!(root_pane.terminal_id, root_pane.pane_id);
}
#[test]
@@ -1624,6 +1628,103 @@ mod tests {
.is_none());
}
+ #[test]
+ fn terminal_target_resolves_terminal_id() {
+ let mut app = test_app();
+ let workspace = Workspace::test_new("terminal-target-id");
+ let pane = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane).unwrap().to_string();
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+
+ let resolved = app.resolve_terminal_target(&terminal_id).unwrap();
+
+ assert_eq!(resolved.ws_idx, 0);
+ assert_eq!(resolved.pane_id, pane);
+ assert_eq!(resolved.terminal_id, terminal_id);
+ }
+
+ #[test]
+ fn terminal_target_resolves_legacy_pane_id() {
+ let mut app = test_app();
+ let workspace = Workspace::test_new("terminal-target-pane");
+ let pane = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane).unwrap().to_string();
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let pane_id = app.public_pane_id(0, pane).unwrap();
+
+ let resolved = app.resolve_terminal_target(&pane_id).unwrap();
+
+ assert_eq!(resolved.pane_id, pane);
+ assert_eq!(resolved.terminal_id, terminal_id);
+ }
+
+ #[test]
+ fn terminal_target_resolves_unique_manual_name() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("terminal-target-name");
+ let pane = workspace.tabs[0].root_pane;
+ workspace.pane_state_mut(pane).unwrap().manual_label = Some("reviewer".into());
+ let terminal_id = workspace.terminal_id(pane).unwrap().to_string();
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+
+ let resolved = app.resolve_terminal_target("reviewer").unwrap();
+
+ assert_eq!(resolved.pane_id, pane);
+ assert_eq!(resolved.terminal_id, terminal_id);
+ }
+
+ #[test]
+ fn terminal_target_reports_missing_target() {
+ let mut app = test_app();
+ app.state.workspaces = vec![Workspace::test_new("terminal-target-missing")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+
+ let err = app.resolve_terminal_target("missing-agent").unwrap_err();
+
+ assert_eq!(
+ err,
+ crate::app::terminal_targets::TerminalTargetError::NotFound {
+ target: "missing-agent".into()
+ }
+ );
+ }
+
+ #[test]
+ fn terminal_target_reports_ambiguous_duplicate_manual_name() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("terminal-target-ambiguous");
+ let first = workspace.tabs[0].root_pane;
+ let second = workspace.test_split(ratatui::layout::Direction::Horizontal);
+ workspace.pane_state_mut(first).unwrap().manual_label = Some("worker".into());
+ workspace.pane_state_mut(second).unwrap().manual_label = Some("worker".into());
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+
+ let err = app.resolve_terminal_target("worker").unwrap_err();
+
+ let crate::app::terminal_targets::TerminalTargetError::Ambiguous { target, candidates } =
+ err
+ else {
+ panic!("expected ambiguous terminal target");
+ };
+ assert_eq!(target, "worker");
+ assert_eq!(candidates.len(), 2);
+ assert!(candidates.iter().all(|candidate| {
+ candidate.terminal_id.starts_with("term_")
+ && candidate.pane_id.starts_with(&app.state.workspaces[0].id)
+ && candidate.workspace_id == app.state.workspaces[0].id
+ && candidate.cwd.is_some()
+ }));
+ }
+
#[tokio::test]
async fn pane_split_request_targets_pane_in_background_tab() {
let _guard = config_env_lock().lock().unwrap();
diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs
new file mode 100644
index 00000000..cf90717d
--- /dev/null
+++ b/src/app/terminal_targets.rs
@@ -0,0 +1,165 @@
+use super::{api_helpers::pane_agent_status, App};
+
+// Staged for #00f: the agent CLI will use this resolver once #00e provides terminal ids.
+#[allow(dead_code)]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct TerminalTarget {
+ pub ws_idx: usize,
+ pub tab_idx: usize,
+ pub pane_id: crate::layout::PaneId,
+ pub terminal_id: String,
+}
+
+// Staged for #00f: ambiguity details are produced before the agent facade consumes them.
+#[allow(dead_code)]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct TerminalTargetCandidate {
+ pub terminal_id: String,
+ pub pane_id: String,
+ pub workspace_id: String,
+ pub tab_id: String,
+ pub cwd: Option<String>,
+ pub agent_status: crate::api::schema::AgentStatus,
+}
+
+// Staged for #00f: target errors are specified now and surfaced by agent commands next.
+#[allow(dead_code)]
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum TerminalTargetError {
+ NotFound {
+ target: String,
+ },
+ Ambiguous {
+ target: String,
+ candidates: Vec<TerminalTargetCandidate>,
+ },
+}
+
+impl App {
+ // Staged for #00f: current pane APIs stay pane-targeted while agent APIs will call this.
+ #[allow(dead_code)]
+ pub(crate) fn resolve_terminal_target(
+ &self,
+ target: &str,
+ ) -> Result<TerminalTarget, TerminalTargetError> {
+ let terminal_matches: Vec<_> = self
+ .terminal_targets()
+ .into_iter()
+ .filter(|candidate| candidate.terminal_id == target)
+ .collect();
+ if let Some(resolved) = self.single_terminal_match(target, terminal_matches)? {
+ return Ok(resolved);
+ }
+
+ if let Some((ws_idx, pane_id)) = self.parse_pane_id(target) {
+ if let Some(resolved) = self.terminal_target_for_pane(ws_idx, pane_id) {
+ return Ok(resolved);
+ }
+ }
+
+ let name_matches: Vec<_> = self
+ .terminal_targets()
+ .into_iter()
+ .filter(|candidate| {
+ self.state.workspaces[candidate.ws_idx]
+ .pane_state(candidate.pane_id)
+ .and_then(|pane| pane.manual_label.as_deref())
+ .is_some_and(|label| label == target)
+ })
+ .collect();
+ if let Some(resolved) = self.single_terminal_match(target, name_matches)? {
+ return Ok(resolved);
+ }
+
+ Err(TerminalTargetError::NotFound {
+ target: target.to_string(),
+ })
+ }
+
+ // Staged for #00f with resolve_terminal_target.
+ #[allow(dead_code)]
+ fn single_terminal_match(
+ &self,
+ target: &str,
+ matches: Vec<TerminalTarget>,
+ ) -> Result<Option<TerminalTarget>, TerminalTargetError> {
+ match matches.len() {
+ 0 => Ok(None),
+ 1 => Ok(matches.into_iter().next()),
+ _ => Err(TerminalTargetError::Ambiguous {
+ target: target.to_string(),
+ candidates: matches
+ .into_iter()
+ .filter_map(|candidate| {
+ self.terminal_target_candidate(candidate.ws_idx, candidate.pane_id)
+ })
+ .collect(),
+ }),
+ }
+ }
+
+ // Staged for #00f with resolve_terminal_target.
+ #[allow(dead_code)]
+ fn terminal_targets(&self) -> Vec<TerminalTarget> {
+ self.state
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ ws.tabs.iter().enumerate().flat_map(move |(tab_idx, tab)| {
+ tab.layout
+ .pane_ids()
+ .into_iter()
+ .filter_map(move |pane_id| {
+ tab.terminal_id(pane_id).map(|terminal_id| TerminalTarget {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ terminal_id: terminal_id.to_string(),
+ })
+ })
+ })
+ })
+ .collect()
+ }
+
+ // Staged for #00f with resolve_terminal_target.
+ #[allow(dead_code)]
+ fn terminal_target_for_pane(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<TerminalTarget> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
+ let terminal_id = ws.terminal_id(pane_id)?.to_string();
+ Some(TerminalTarget {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ terminal_id,
+ })
+ }
+
+ // Staged for #00f with resolve_terminal_target.
+ #[allow(dead_code)]
+ fn terminal_target_candidate(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<TerminalTargetCandidate> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
+ let pane = ws.pane_state(pane_id)?;
+ Some(TerminalTargetCandidate {
+ terminal_id: ws.terminal_id(pane_id)?.to_string(),
+ pane_id: self.public_pane_id(ws_idx, pane_id)?,
+ workspace_id: self.public_workspace_id(ws_idx),
+ tab_id: self.public_tab_id(ws_idx, tab_idx)?,
+ cwd: ws.tabs[tab_idx]
+ .cwd_for_pane(pane_id)
+ .map(|cwd| cwd.display().to_string()),
+ agent_status: pane_agent_status(pane.state, pane.seen),
+ })
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
index 6aee92ba..205ea660 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,12 +6,12 @@ use serde::Serialize;
use crate::api;
use crate::api::schema::{
- AgentStatus, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneAgentState,
- PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, PaneSendInputParams,
- PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams,
- PingParams, ReadFormat, ReadSource, Request, SplitDirection, Subscription, TabCreateParams,
- TabListParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams,
- WorkspaceTarget,
+ AgentReadParams, AgentRenameParams, AgentSendParams, AgentStatus, AgentTarget, EmptyParams,
+ IntegrationTarget, Method, OutputMatch, PaneAgentState, PaneListParams, PaneReadParams,
+ PaneRenameParams, PaneReportAgentParams, PaneSendInputParams, PaneSendKeysParams,
+ PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams, PingParams,
+ ReadFormat, ReadSource, Request, SplitDirection, Subscription, TabCreateParams, TabListParams,
+ TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget,
};
pub enum CommandOutcome {
@@ -34,6 +34,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
"status" => run_status_command(&args[2..])?,
"workspace" => run_workspace_command(&args[2..])?,
"tab" => run_tab_command(&args[2..])?,
+ "agent" => run_agent_command(&args[2..])?,
"pane" => run_pane_command(&args[2..])?,
"wait" => run_wait_command(&args[2..])?,
"integration" => run_integration_command(&args[2..])?,
@@ -265,6 +266,30 @@ fn run_tab_command(args: &[String]) -> std::io::Result<i32> {
}
}
+fn run_agent_command(args: &[String]) -> std::io::Result<i32> {
+ let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
+ print_agent_help();
+ return Ok(2);
+ };
+
+ match subcommand {
+ "list" => agent_list(&args[1..]),
+ "get" => agent_get(&args[1..]),
+ "read" => agent_read(&args[1..]),
+ "send" => agent_send(&args[1..]),
+ "rename" => agent_rename(&args[1..]),
+ "focus" => agent_focus(&args[1..]),
+ "help" | "--help" | "-h" => {
+ print_agent_help();
+ Ok(0)
+ }
+ _ => {
+ print_agent_help();
+ Ok(2)
+ }
+ }
+}
+
fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_pane_help();
@@ -746,6 +771,156 @@ fn tab_close(args: &[String]) -> std::io::Result<i32> {
})?)
}
+fn agent_list(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr agent list");
+ return Ok(2);
+ }
+
+ print_response(&send_request(&Request {
+ id: "cli:agent:list".into(),
+ method: Method::AgentList(EmptyParams::default()),
+ })?)
+}
+
+fn agent_get(args: &[String]) -> std::io::Result<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr agent get <target>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr agent get <target>");
+ return Ok(2);
+ }
+
+ print_response(&send_request(&Request {
+ id: "cli:agent:get".into(),
+ method: Method::AgentGet(AgentTarget {
+ target: target.clone(),
+ }),
+ })?)
+}
+
+fn agent_focus(args: &[String]) -> std::io::Result<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr agent focus <target>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr agent focus <target>");
+ return Ok(2);
+ }
+
+ print_response(&send_request(&Request {
+ id: "cli:agent:focus".into(),
+ method: Method::AgentFocus(AgentTarget {
+ target: target.clone(),
+ }),
+ })?)
+}
+
+fn agent_rename(args: &[String]) -> std::io::Result<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr agent rename <target> <name>|--clear");
+ return Ok(2);
+ };
+ if args.len() < 2 {
+ eprintln!("usage: herdr agent rename <target> <name>|--clear");
+ return Ok(2);
+ }
+ let name = if args.len() == 2 && args[1] == "--clear" {
+ None
+ } else {
+ Some(args[1..].join(" "))
+ };
+
+ print_response(&send_request(&Request {
+ id: "cli:agent:rename".into(),
+ method: Method::AgentRename(AgentRenameParams {
+ target: target.clone(),
+ name,
+ }),
+ })?)
+}
+
+fn agent_send(args: &[String]) -> std::io::Result<i32> {
+ if args.len() < 2 {
+ eprintln!("usage: herdr agent send <target> <text>");
+ return Ok(2);
+ }
+
+ print_response(&send_request(&Request {
+ id: "cli:agent:send".into(),
+ method: Method::AgentSend(AgentSendParams {
+ target: args[0].clone(),
+ text: args[1..].join(" "),
+ }),
+ })?)
+}
+
+fn agent_read(args: &[String]) -> std::io::Result<i32> {
+ let Some(target) = args.first() else {
+ eprintln!("usage: herdr agent read <target> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]");
+ return Ok(2);
+ };
+
+ let mut source = ReadSource::Recent;
+ let mut lines = None;
+ let mut format = ReadFormat::Text;
+ let mut strip_ansi = true;
+
+ let mut index = 1;
+ while index < args.len() {
+ match args[index].as_str() {
+ "--source" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --source");
+ return Ok(2);
+ };
+ source = parse_read_source(value)?;
+ index += 2;
+ }
+ "--lines" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --lines");
+ return Ok(2);
+ };
+ lines = Some(parse_u32_flag("--lines", value)?);
+ index += 2;
+ }
+ "--format" => {
+ let Some(value) = args.get(index + 1) else {
+ eprintln!("missing value for --format");
+ return Ok(2);
+ };
+ format = parse_read_format(value)?;
+ strip_ansi = !matches!(format, ReadFormat::Ansi);
+ index += 2;
+ }
+ "--ansi" => {
+ format = ReadFormat::Ansi;
+ strip_ansi = false;
+ index += 1;
+ }
+ other => {
+ eprintln!("unknown option: {other}");
+ return Ok(2);
+ }
+ }
+ }
+
+ print_response(&send_request(&Request {
+ id: "cli:agent:read".into(),
+ method: Method::AgentRead(AgentReadParams {
+ target: target.clone(),
+ source,
+ lines,
+ format,
+ strip_ansi,
+ }),
+ })?)
+}
+
fn pane_list(args: &[String]) -> std::io::Result<i32> {
let mut workspace_id = None;
@@ -1628,6 +1803,17 @@ fn print_tab_help() {
eprintln!(" herdr tab close <tab_id>");
}
+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 rename <target> <name>|--clear");
+ eprintln!(" herdr agent focus <target>");
+ eprintln!(" targets accept terminal ids, unique agent names, and legacy pane ids");
+}
+
fn print_pane_help() {
eprintln!("herdr pane commands:");
eprintln!(" herdr pane list [--workspace <workspace_id>]");
diff --git a/src/main.rs b/src/main.rs
index c852c33b..441f3588 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -42,6 +42,7 @@ mod selection;
mod server;
mod session;
mod sound;
+mod terminal;
mod terminal_notify;
mod terminal_theme;
mod ui;
@@ -280,6 +281,7 @@ fn main() -> io::Result<()> {
println!(" herdr server reload-config");
println!(" herdr workspace <subcommand> ...");
println!(" herdr tab <subcommand> ...");
+ println!(" herdr agent <subcommand> ...");
println!(" herdr pane <subcommand> ...");
println!(" herdr wait <subcommand> ...");
println!(" herdr session <subcommand> ...");
@@ -307,6 +309,10 @@ fn main() -> io::Result<()> {
),
("herdr tab <subcommand>", "Tab helpers over the socket API"),
(
+ "herdr agent <subcommand>",
+ "Agent/terminal helpers over the socket API",
+ ),
+ (
"herdr pane <subcommand>",
"Pane control helpers over the socket API",
),
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 7d623173..1e10df6d 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -111,6 +111,7 @@ fn restore_tab(
let mut panes = HashMap::new();
let mut pane_cwds = HashMap::new();
+ let mut terminal_ids = HashMap::new();
let mut runtimes = HashMap::new();
for id in &pane_ids {
let saved_cwd = reverse_id_map
@@ -157,6 +158,7 @@ fn restore_tab(
pane_state.manual_label = saved_label;
panes.insert(*id, pane_state);
pane_cwds.insert(*id, cwd.clone());
+ terminal_ids.insert(*id, crate::terminal::TerminalId::alloc());
runtimes.insert(*id, runtime);
}
Err(e) => {
@@ -198,6 +200,7 @@ fn restore_tab(
layout,
panes,
pane_cwds,
+ terminal_ids,
runtimes,
zoomed: snap.zoomed,
events,
diff --git a/src/terminal/id.rs b/src/terminal/id.rs
new file mode 100644
index 00000000..53ef6786
--- /dev/null
+++ b/src/terminal/id.rs
@@ -0,0 +1,29 @@
+use std::fmt;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+/// Opaque identity for a server-owned terminal.
+///
+/// During the pane-backed transition this is stored one-to-one beside panes,
+/// but callers must not derive it from a pane id or layout position.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
+pub struct TerminalId(String);
+
+static NEXT_TERMINAL_ID: AtomicU64 = AtomicU64::new(1);
+
+impl TerminalId {
+ pub fn alloc() -> Self {
+ let micros = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_micros())
+ .unwrap_or(0);
+ let counter = NEXT_TERMINAL_ID.fetch_add(1, Ordering::Relaxed);
+ Self(format!("term_{micros:x}{counter:x}"))
+ }
+}
+
+impl fmt::Display for TerminalId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.0)
+ }
+}
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
new file mode 100644
index 00000000..489f7876
--- /dev/null
+++ b/src/terminal/mod.rs
@@ -0,0 +1,3 @@
+mod id;
+
+pub use id::TerminalId;
diff --git a/src/workspace.rs b/src/workspace.rs
index de030a80..46ad14fa 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -13,6 +13,7 @@ use crate::layout::PaneId;
#[cfg(test)]
use crate::layout::TileLayout;
use crate::pane::{PaneRuntime, PaneState};
+use crate::terminal::TerminalId;
mod aggregate;
mod git;
@@ -418,6 +419,10 @@ impl Workspace {
self.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id))
}
+ pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> {
+ self.tabs.iter().find_map(|tab| tab.terminal_id(pane_id))
+ }
+
pub fn focused_pane_id(&self) -> Option<PaneId> {
self.active_tab().map(|tab| tab.layout.focused())
}
@@ -497,6 +502,8 @@ impl Workspace {
panes.insert(root_id, PaneState::new());
let mut pane_cwds = HashMap::new();
pane_cwds.insert(root_id, identity_cwd.clone());
+ let mut terminal_ids = HashMap::new();
+ terminal_ids.insert(root_id, TerminalId::alloc());
let tab = Tab {
custom_name: None,
number: 1,
@@ -504,6 +511,7 @@ impl Workspace {
layout,
panes,
pane_cwds,
+ terminal_ids,
runtimes: HashMap::new(),
zoomed: false,
events,
@@ -531,6 +539,7 @@ impl Workspace {
tab.panes.insert(new_id, PaneState::new());
let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
tab.pane_cwds.insert(new_id, cwd);
+ tab.terminal_ids.insert(new_id, TerminalId::alloc());
self.register_new_pane(new_id);
new_id
}
@@ -545,6 +554,8 @@ impl Workspace {
let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
let mut pane_cwds = HashMap::new();
pane_cwds.insert(root_id, cwd);
+ let mut terminal_ids = HashMap::new();
+ terminal_ids.insert(root_id, TerminalId::alloc());
let tab = Tab {
custom_name: name.map(str::to_string),
number: self.tabs.len() + 1,
@@ -552,6 +563,7 @@ impl Workspace {
layout,
panes,
pane_cwds,
+ terminal_ids,
runtimes: HashMap::new(),
zoomed: false,
events,
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
index f8b31eb5..6f3177be 100644
--- a/src/workspace/tab.rs
+++ b/src/workspace/tab.rs
@@ -9,6 +9,7 @@ use tokio::sync::{mpsc, Notify};
use crate::events::AppEvent;
use crate::layout::{PaneId, TileLayout};
use crate::pane::{PaneRuntime, PaneState};
+use crate::terminal::TerminalId;
pub struct Tab {
pub custom_name: Option<String>,
@@ -20,6 +21,8 @@ pub struct Tab {
pub panes: HashMap<PaneId, PaneState>,
/// Best-effort cwd cache per pane. Live runtime cwd wins when available.
pub pane_cwds: HashMap<PaneId, PathBuf>,
+ /// One-to-one terminal identity for each pane-backed PTY during the transition.
+ pub terminal_ids: HashMap<PaneId, TerminalId>,
/// Pane runtimes — only present in production (empty in tests).
pub runtimes: HashMap<PaneId, PaneRuntime>,
pub zoomed: bool,
@@ -66,6 +69,8 @@ impl Tab {
panes.insert(root_id, PaneState::new());
let mut pane_cwds = HashMap::new();
pane_cwds.insert(root_id, initial_cwd);
+ let mut terminal_ids = HashMap::new();
+ terminal_ids.insert(root_id, TerminalId::alloc());
let mut runtimes = HashMap::new();
runtimes.insert(root_id, runtime);
@@ -76,6 +81,7 @@ impl Tab {
layout,
panes,
pane_cwds,
+ terminal_ids,
runtimes,
zoomed: false,
events,
@@ -182,6 +188,7 @@ impl Tab {
};
self.panes.insert(new_id, PaneState::new());
self.pane_cwds.insert(new_id, actual_cwd);
+ self.terminal_ids.insert(new_id, TerminalId::alloc());
self.runtimes.insert(new_id, runtime);
self.zoomed = false;
Ok(new_id)
@@ -218,6 +225,7 @@ impl Tab {
self.panes.remove(&pane_id);
self.pane_cwds.remove(&pane_id);
+ self.terminal_ids.remove(&pane_id);
let runtime = self.runtimes.remove(&pane_id);
self.zoomed = false;
if let Some(next_root) = next_root {
@@ -237,6 +245,10 @@ impl Tab {
self.runtimes.get(&self.layout.focused())
}
+ pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> {
+ self.terminal_ids.get(&pane_id)
+ }
+
pub fn cwd_for_pane(&self, pane_id: PaneId) -> Option<PathBuf> {
self.runtimes
.get(&pane_id)
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index 30840e91..a2e6ef76 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -273,6 +273,12 @@ fn workspace_list_and_create_round_trip() {
.as_str()
.unwrap()
.to_string();
+ let root_terminal_id = created["result"]["root_pane"]["terminal_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert!(root_terminal_id.starts_with("term_"));
+ assert_ne!(root_terminal_id, root_pane_id);
assert_eq!(created["result"]["workspace"]["number"], 1);
assert_eq!(created["result"]["workspace"]["focused"], true);
assert_eq!(created["result"]["workspace"]["tab_count"], 1);
@@ -307,6 +313,7 @@ fn workspace_list_and_create_round_trip() {
assert_eq!(panes[0]["tab_id"], active_tab_id);
let pane_id = panes[0]["pane_id"].as_str().unwrap().to_string();
assert_eq!(pane_id, root_pane_id);
+ assert_eq!(panes[0]["terminal_id"], root_terminal_id);
let pane = send_request(
&socket_path,
@@ -316,6 +323,7 @@ fn workspace_list_and_create_round_trip() {
),
);
assert_eq!(pane["result"]["pane"]["pane_id"], pane_id);
+ assert_eq!(pane["result"]["pane"]["terminal_id"], root_terminal_id);
let read = send_request(
&socket_path,
@@ -467,6 +475,12 @@ fn tab_methods_round_trip_over_socket() {
.as_str()
.unwrap()
.to_string();
+ let second_root_terminal_id = tab_created["result"]["root_pane"]["terminal_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert!(second_root_terminal_id.starts_with("term_"));
+ assert_ne!(second_root_terminal_id, second_root_pane_id);
assert_eq!(second_tab_id, format!("{workspace_id}:2"));
assert_eq!(tab_created["result"]["tab"]["focused"], true);
assert_eq!(tab_created["result"]["root_pane"]["tab_id"], second_tab_id);
@@ -490,9 +504,9 @@ fn tab_methods_round_trip_over_socket() {
),
);
let panes = panes["result"]["panes"].as_array().unwrap();
- assert!(panes
- .iter()
- .any(|pane| pane["pane_id"] == second_root_pane_id));
+ assert!(panes.iter().any(|pane| {
+ pane["pane_id"] == second_root_pane_id && pane["terminal_id"] == second_root_terminal_id
+ }));
assert_eq!(tabs[1]["tab_id"], second_tab_id);
let tab_get = send_request(
@@ -537,6 +551,155 @@ fn tab_methods_round_trip_over_socket() {
#[cfg(not(target_os = "macos"))]
#[test]
+fn agent_methods_round_trip_over_socket() {
+ 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 child = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_ws","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let workspace_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let terminal_id = created["result"]["root_pane"]["terminal_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let renamed = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_rename_pane","method":"pane.rename","params":{{"pane_id":"{}","label":"worker"}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(renamed["result"]["pane"]["label"], "worker");
+
+ let reported = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_report","method":"pane.report_agent","params":{{"pane_id":"{}","source":"test","agent":"pi","state":"working"}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(reported["result"]["type"], "ok");
+
+ let listed = send_request(
+ &socket_path,
+ r#"{"id":"agent_list","method":"agent.list","params":{}}"#,
+ );
+ let agents = listed["result"]["agents"].as_array().unwrap();
+ assert_eq!(agents.len(), 1);
+ assert_eq!(agents[0]["terminal_id"], terminal_id);
+ assert_eq!(agents[0]["name"], "worker");
+ assert_eq!(agents[0]["agent"], "pi");
+ assert_eq!(agents[0]["agent_status"], "working");
+ assert_eq!(agents[0]["pane_id"], pane_id);
+
+ let fetched_by_name = send_request(
+ &socket_path,
+ r#"{"id":"agent_get_name","method":"agent.get","params":{"target":"worker"}}"#,
+ );
+ assert_eq!(
+ fetched_by_name["result"]["agent"]["terminal_id"],
+ terminal_id
+ );
+
+ let fetched_by_terminal = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_get_terminal","method":"agent.get","params":{{"target":"{}"}}}}"#,
+ terminal_id
+ ),
+ );
+ assert_eq!(fetched_by_terminal["result"]["agent"]["name"], "worker");
+
+ let read = send_request(
+ &socket_path,
+ r#"{"id":"agent_read","method":"agent.read","params":{"target":"worker","source":"visible"}}"#,
+ );
+ assert_eq!(read["result"]["type"], "pane_read");
+ assert_eq!(read["result"]["read"]["pane_id"], pane_id);
+
+ let sent = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_send","method":"agent.send","params":{{"target":"{}","text":"echo agent-send-ok\n"}}}}"#,
+ terminal_id
+ ),
+ );
+ assert_eq!(sent["result"]["type"], "ok");
+
+ let tab_created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_tab","method":"tab.create","params":{{"workspace_id":"{}","focus":false}}}}"#,
+ workspace_id
+ ),
+ );
+ let second_tab_id = tab_created["result"]["tab"]["tab_id"].as_str().unwrap();
+ let second_pane_id = tab_created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap();
+ let second_terminal_id = tab_created["result"]["root_pane"]["terminal_id"]
+ .as_str()
+ .unwrap();
+
+ let second_renamed = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"agent_second_rename","method":"pane.rename","params":{{"pane_id":"{}","label":"reviewer"}}}}"#,
+ second_pane_id
+ ),
+ );
+ assert_eq!(second_renamed["result"]["pane"]["label"], "reviewer");
+
+ let duplicate = send_request(
+ &socket_path,
+ r#"{"id":"agent_duplicate","method":"agent.rename","params":{"target":"reviewer","name":"worker"}}"#,
+ );
+ assert_eq!(duplicate["error"]["code"], "agent_name_taken");
+ assert!(duplicate["error"]["message"]
+ .as_str()
+ .unwrap()
+ .contains(&terminal_id));
+
+ let agent_renamed = send_request(
+ &socket_path,
+ r#"{"id":"agent_rename","method":"agent.rename","params":{"target":"reviewer","name":"qa"}}"#,
+ );
+ assert_eq!(agent_renamed["result"]["agent"]["name"], "qa");
+
+ let focused = send_request(
+ &socket_path,
+ r#"{"id":"agent_focus","method":"agent.focus","params":{"target":"qa"}}"#,
+ );
+ assert_eq!(
+ focused["result"]["agent"]["terminal_id"],
+ second_terminal_id
+ );
+ assert_eq!(focused["result"]["agent"]["tab_id"], second_tab_id);
+ assert_eq!(focused["result"]["agent"]["focused"], true);
+
+ cleanup_spawned_herdr(child, base);
+}
+
+#[test]
fn tab_create_with_no_focus_preserves_active_tab() {
let _lock = test_lock();
let base = unique_test_dir();
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index 52e99edb..4302687e 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -1220,6 +1220,63 @@ fn tab_management_commands_work() {
}
#[test]
+fn agent_commands_work() {
+ 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 herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = run_cli(
+ &socket_path,
+ &["workspace", "create", "--cwd", base.to_str().unwrap()],
+ );
+ assert!(created.status.success());
+ let created_json: serde_json::Value = serde_json::from_slice(&created.stdout).unwrap();
+ let root_pane_id = created_json["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let terminal_id = created_json["result"]["root_pane"]["terminal_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let renamed = run_cli(&socket_path, &["pane", "rename", &root_pane_id, "worker"]);
+ assert!(renamed.status.success());
+
+ let listed = run_cli_json(&socket_path, &["agent", "list"]);
+ assert_eq!(listed["result"]["type"], "agent_list");
+ assert_eq!(listed["result"]["agents"][0]["terminal_id"], terminal_id);
+ assert_eq!(listed["result"]["agents"][0]["name"], "worker");
+
+ let fetched = run_cli_json(&socket_path, &["agent", "get", "worker"]);
+ assert_eq!(fetched["result"]["agent"]["pane_id"], root_pane_id);
+
+ let read = run_cli_json(
+ &socket_path,
+ &["agent", "read", &terminal_id, "--source", "visible"],
+ );
+ assert_eq!(read["result"]["type"], "pane_read");
+
+ let sent = run_cli(
+ &socket_path,
+ &["agent", "send", "worker", "echo cli-agent-ok\n"],
+ );
+ assert!(sent.status.success());
+
+ let agent_renamed = run_cli_json(&socket_path, &["agent", "rename", "worker", "reviewer"]);
+ assert_eq!(agent_renamed["result"]["agent"]["name"], "reviewer");
+
+ let focused = run_cli_json(&socket_path, &["agent", "focus", "reviewer"]);
+ assert_eq!(focused["result"]["agent"]["focused"], true);
+
+ cleanup_spawned_herdr(herdr, base);
+}
+
+#[test]
fn pane_close_only_removes_the_target_tab_when_other_tabs_exist() {
let base = unique_test_dir();
let config_home = base.join("config");