aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-16 22:12:44 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-16 22:17:09 +0300
commit0b831c86274486af9f9f7052c2534374b498bb3a (patch)
tree3e8130be85cb387c8c3e57591617c4c7be0a26ec
parent318e6a3033b61082d9c330c79ef584cf37e6c6d0 (diff)
feat: allow hook-reported custom agent labels
-rw-r--r--README.md2
-rw-r--r--SOCKET_API.md81
-rw-r--r--src/app/actions.rs77
-rw-r--r--src/app/api.rs30
-rw-r--r--src/app/mod.rs47
-rw-r--r--src/detect.rs53
-rw-r--r--src/events.rs5
-rw-r--r--src/pane.rs126
-rw-r--r--src/workspace.rs50
-rw-r--r--tests/api_ping.rs45
10 files changed, 372 insertions, 144 deletions
diff --git a/README.md b/README.md
index 7a29a1d9..20c3539d 100644
--- a/README.md
+++ b/README.md
@@ -154,6 +154,8 @@ heuristics also exist for these agents but have not been fully tested yet:
for any other cli agent, herdr still works as a terminal-native multiplexer. you still get workspaces, panes, tiling, and notifications even when richer state detection is not available yet.
+for agents outside the built-in list, herdr also supports custom hook or plugin integrations over the local socket api. a custom integration can report any non-empty agent label, and that label will appear in pane info, events, and the sidebar without needing first-party built-in detection. see [`SOCKET_API.md`](./SOCKET_API.md) for the raw api surface.
+
## optional direct integrations
automatic detection works out of the box. if an agent exposes hooks or plugins, the better path is to let it report state to herdr over the local socket api.
diff --git a/SOCKET_API.md b/SOCKET_API.md
index 9a74e3ad..a7805cee 100644
--- a/SOCKET_API.md
+++ b/SOCKET_API.md
@@ -141,6 +141,13 @@ for backward compatibility, requests also accept the older positional forms like
"revision": 0
}
```
+
+`agent` is an optional display label string.
+
+- when herdr detects a built-in agent, this is that built-in name like `pi` or `claude`
+- when a hook or plugin reports a custom agent through `pane.report_agent`, this can be any non-empty label like `hermes`
+- when no agent identity is known, it is omitted
+
`pane_read` responses contain objects like:
```json
@@ -189,6 +196,9 @@ for backward compatibility, requests also accept the older positional forms like
| `pane.send_text` | send literal text without Enter | `ok` |
| `pane.send_keys` | send keypresses like `Enter` | `ok` |
| `pane.send_input` | send literal text plus keypresses in order | `ok` |
+| `pane.report_agent` | report hook-authoritative agent label and state for a pane | `ok` |
+| `pane.clear_agent_authority` | clear hook-authoritative agent state for a pane | `ok` |
+| `pane.release_agent` | release a pane from the reported agent back to shell state | `ok` |
| `pane.close` | close a pane | `ok` |
| `pane.wait_for_output` | one-shot blocking wait for text | `output_matched` |
| `events.subscribe` | start a long-lived subscription stream | `subscription_started` ack |
@@ -552,6 +562,75 @@ this sends text plus encoded keypresses in order within one request. when bracke
`text` and `keys` are both optional, but at least one should usually be present.
+### `pane.report_agent`
+
+use this when an agent hook or plugin wants to report a semantic state directly over the socket api.
+
+params:
+
+```json
+{
+ "pane_id": "1-1",
+ "source": "custom:hermes",
+ "agent": "hermes",
+ "state": "working",
+ "message": "running tools"
+}
+```
+
+notes:
+
+- `source` is required and identifies the reporting integration instance
+- `agent` is required and may be any non-empty label string
+- built-in names like `pi` are normalized to their public label form
+- custom labels like `hermes` are accepted as-is
+- while this authority is active, the reported `agent` and `state` override heuristic display for that pane
+- process detection still owns pane liveness and fallback when hook authority is cleared or released
+- `message` is optional metadata for the reporting integration
+
+returns `ok`.
+
+### `pane.clear_agent_authority`
+
+params:
+
+```json
+{
+ "pane_id": "1-1",
+ "source": "custom:hermes"
+}
+```
+
+notes:
+
+- `source` is optional
+- when `source` is omitted, any hook authority for that pane is cleared
+- when `source` is present, only that reporting source is cleared
+
+returns `ok`.
+
+### `pane.release_agent`
+
+use this when the reported agent is exiting cleanly and wants herdr to drop agent identity immediately instead of waiting for fallback detection.
+
+params:
+
+```json
+{
+ "pane_id": "1-1",
+ "source": "custom:hermes",
+ "agent": "hermes"
+}
+```
+
+notes:
+
+- `agent` uses the same non-empty label rules as `pane.report_agent`
+- this clears the pane's effective agent identity immediately when the source and label match the active authority
+- for built-in detected agents, herdr also applies its normal short reacquire suppression during graceful release
+
+returns `ok`.
+
### `pane.close`
params:
@@ -791,6 +870,8 @@ example pushed `pane.agent_status_changed` event:
}
}
```
+
+`agent` in pushed events follows the same rules as `pane_info.agent`: it may be a built-in detected name, a custom hook-reported label, or omitted.
## cli wrappers
these commands talk to the same local socket surface and are usually the easiest starting point for shell scripts and coding agents.
diff --git a/src/app/actions.rs b/src/app/actions.rs
index ffb41ba1..3617bb07 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -53,20 +53,8 @@ fn notification_toast_for_state_change(
}
}
-fn agent_label(agent: Agent) -> &'static str {
- match agent {
- crate::detect::Agent::Pi => "pi",
- crate::detect::Agent::Claude => "claude",
- crate::detect::Agent::Codex => "codex",
- crate::detect::Agent::Gemini => "gemini",
- crate::detect::Agent::Cursor => "cursor",
- crate::detect::Agent::Cline => "cline",
- crate::detect::Agent::OpenCode => "opencode",
- crate::detect::Agent::GithubCopilot => "copilot",
- crate::detect::Agent::Kimi => "kimi",
- crate::detect::Agent::Droid => "droid",
- crate::detect::Agent::Amp => "amp",
- }
+fn toast_agent_label(agent_label: &str) -> &str {
+ agent_label
}
fn notification_context(
@@ -84,13 +72,15 @@ fn notification_context(
context
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneStateUpdate {
pub pane_id: PaneId,
pub ws_idx: usize,
- pub previous_agent: Option<Agent>,
+ pub previous_agent_label: Option<String>,
+ pub previous_known_agent: Option<Agent>,
pub previous_state: AgentState,
- pub agent: Option<Agent>,
+ pub agent_label: Option<String>,
+ pub known_agent: Option<Agent>,
pub state: AgentState,
}
@@ -497,12 +487,12 @@ impl AppState {
AppEvent::HookStateReported {
pane_id,
source,
- agent,
+ agent_label,
state,
message,
} => self
.update_pane_state(pane_id, |pane| {
- pane.set_hook_authority(source, agent, state, message)
+ pane.set_hook_authority(source, agent_label, state, message)
})
.into_iter()
.collect(),
@@ -513,9 +503,10 @@ impl AppState {
AppEvent::HookAgentReleased {
pane_id,
source,
- agent,
+ agent_label,
+ ..
} => self
- .update_pane_state(pane_id, |pane| pane.release_agent(&source, agent))
+ .update_pane_state(pane_id, |pane| pane.release_agent(&source, &agent_label))
.into_iter()
.collect(),
// Intercepted in App::handle_internal_event before reaching this
@@ -539,22 +530,25 @@ impl AppState {
.find_map(|tab| tab.panes.get_mut(&pane_id))?;
update(pane)?
};
- self.apply_pane_state_change(ws_idx, pane_id, change);
- Some(PaneStateUpdate {
+ let update = PaneStateUpdate {
pane_id,
ws_idx,
- previous_agent: change.previous_agent,
+ previous_agent_label: change.previous_agent_label.clone(),
+ previous_known_agent: change.previous_known_agent,
previous_state: change.previous_state,
- agent: change.agent,
+ agent_label: change.agent_label.clone(),
+ known_agent: change.known_agent,
state: change.state,
- })
+ };
+ self.apply_pane_state_change(ws_idx, pane_id, &change);
+ Some(update)
}
fn apply_pane_state_change(
&mut self,
ws_idx: usize,
pane_id: PaneId,
- change: EffectiveStateChange,
+ change: &EffectiveStateChange,
) {
let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id);
let Some(pane) = self.workspaces[ws_idx]
@@ -571,7 +565,7 @@ impl AppState {
pane.seen = false;
}
- if self.sound.allows(change.agent) {
+ if self.sound.allows(change.known_agent) {
if let Some(sound) = notification_sound_for_state_change(
is_active_tab,
change.previous_state,
@@ -582,8 +576,8 @@ impl AppState {
}
if self.toast_config.enabled {
- if let (Some(agent), Some(kind)) = (
- change.agent,
+ if let (Some(agent_label), Some(kind)) = (
+ change.agent_label.as_deref(),
notification_toast_for_state_change(
is_active_tab,
change.previous_state,
@@ -598,7 +592,7 @@ impl AppState {
let context = notification_context(&self.workspaces[ws_idx], ws_idx, pane_id);
self.toast = Some(ToastNotification {
kind,
- title: format!("{} {}", agent_label(agent), event_text),
+ title: format!("{} {}", toast_agent_label(agent_label), event_text),
context,
});
}
@@ -927,6 +921,27 @@ mod tests {
}
#[test]
+ fn hook_reported_unknown_agent_sets_toast_title_from_label() {
+ let mut state = app_with_workspaces(&["active", "background"]);
+ state.active = Some(0);
+ state.toast_config.enabled = true;
+ let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
+
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id: bg_pane_id,
+ source: "custom:hermes".into(),
+ agent_label: "hermes".into(),
+ state: AgentState::Blocked,
+ message: None,
+ });
+
+ let toast = state.toast.as_ref().unwrap();
+ assert_eq!(toast.kind, ToastKind::NeedsAttention);
+ assert_eq!(toast.title, "hermes needs attention");
+ assert_eq!(toast.context, "background · 2");
+ }
+
+ #[test]
fn background_idle_sets_finished_toast() {
let mut state = app_with_workspaces(&["active", "background"]);
state.active = Some(0);
diff --git a/src/app/api.rs b/src/app/api.rs
index 1e969b64..2ed224c7 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -1,10 +1,21 @@
//! API request handling and response building for [`App`].
use super::{
- agent_name, detect_state_from_api, encode_api_keys, encode_api_text, pane_agent_status,
- parse_agent_name, tab_attention_priority, App, Mode,
+ detect_state_from_api, encode_api_keys, encode_api_text, pane_agent_status,
+ tab_attention_priority, App, Mode,
};
+fn normalize_reported_agent_label(agent: &str) -> Option<String> {
+ let trimmed = agent.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ if let Some(agent) = crate::detect::parse_agent_label(trimmed) {
+ return Some(crate::detect::agent_label(agent).to_string());
+ }
+ Some(trimmed.to_string())
+}
+
pub(super) fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool {
use crate::api::schema::Method;
@@ -775,12 +786,12 @@ impl App {
})
.unwrap();
};
- let Some(agent) = parse_agent_name(&params.agent) else {
+ let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
return serde_json::to_string(&ErrorResponse {
id: request.id,
error: ErrorBody {
code: "invalid_agent".into(),
- message: format!("unsupported agent {}", params.agent),
+ message: "agent label must not be empty".into(),
},
})
.unwrap();
@@ -788,7 +799,7 @@ impl App {
self.handle_internal_event(crate::events::AppEvent::HookStateReported {
pane_id,
source: params.source,
- agent,
+ agent_label,
state: detect_state_from_api(params.state),
message: params.message,
});
@@ -828,12 +839,12 @@ impl App {
})
.unwrap();
};
- let Some(agent) = parse_agent_name(&params.agent) else {
+ let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
return serde_json::to_string(&ErrorResponse {
id: request.id,
error: ErrorBody {
code: "invalid_agent".into(),
- message: format!("unsupported agent {}", params.agent),
+ message: "agent label must not be empty".into(),
},
})
.unwrap();
@@ -841,7 +852,8 @@ impl App {
self.handle_internal_event(crate::events::AppEvent::HookAgentReleased {
pane_id,
source: params.source,
- agent,
+ known_agent: crate::detect::parse_agent_label(&agent_label),
+ agent_label,
});
SuccessResponse {
id: request.id,
@@ -1185,7 +1197,7 @@ impl App {
cwd: runtime
.and_then(|rt| rt.cwd())
.map(|cwd| cwd.display().to_string()),
- agent: pane.detected_agent.map(agent_name),
+ agent: pane.effective_agent_label().map(str::to_string),
agent_status: pane_agent_status(pane.state, pane.seen),
revision: 0,
})
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 11a034f9..7f821da2 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -795,8 +795,13 @@ impl App {
}
}
- let released_agent = if let AppEvent::HookAgentReleased { pane_id, agent, .. } = &ev {
- Some((*pane_id, *agent))
+ let released_agent = if let AppEvent::HookAgentReleased {
+ pane_id,
+ known_agent,
+ ..
+ } = &ev
+ {
+ known_agent.map(|agent| (*pane_id, agent))
} else {
None
};
@@ -827,13 +832,13 @@ impl App {
};
let workspace_id = self.public_workspace_id(update.ws_idx);
- if update.previous_agent != update.agent {
+ if update.previous_agent_label != update.agent_label {
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneAgentDetected,
data: crate::api::schema::EventData::PaneAgentDetected {
pane_id: pane_id.clone(),
workspace_id: workspace_id.clone(),
- agent: update.agent.map(agent_name),
+ agent: update.agent_label.clone(),
},
});
}
@@ -1275,40 +1280,6 @@ fn pane_agent_status(
}
}
-fn parse_agent_name(agent: &str) -> Option<crate::detect::Agent> {
- match agent {
- "pi" => Some(crate::detect::Agent::Pi),
- "claude" => Some(crate::detect::Agent::Claude),
- "codex" => Some(crate::detect::Agent::Codex),
- "gemini" => Some(crate::detect::Agent::Gemini),
- "cursor" => Some(crate::detect::Agent::Cursor),
- "cline" => Some(crate::detect::Agent::Cline),
- "opencode" => Some(crate::detect::Agent::OpenCode),
- "copilot" => Some(crate::detect::Agent::GithubCopilot),
- "kimi" => Some(crate::detect::Agent::Kimi),
- "droid" => Some(crate::detect::Agent::Droid),
- "amp" => Some(crate::detect::Agent::Amp),
- _ => None,
- }
-}
-
-fn agent_name(agent: crate::detect::Agent) -> String {
- match agent {
- crate::detect::Agent::Pi => "pi",
- crate::detect::Agent::Claude => "claude",
- crate::detect::Agent::Codex => "codex",
- crate::detect::Agent::Gemini => "gemini",
- crate::detect::Agent::Cursor => "cursor",
- crate::detect::Agent::Cline => "cline",
- crate::detect::Agent::OpenCode => "opencode",
- crate::detect::Agent::GithubCopilot => "copilot",
- crate::detect::Agent::Kimi => "kimi",
- crate::detect::Agent::Droid => "droid",
- crate::detect::Agent::Amp => "amp",
- }
- .to_string()
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/detect.rs b/src/detect.rs
index 178fe0a5..df5bbae4 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -32,6 +32,40 @@ pub enum Agent {
Amp,
}
+pub fn agent_label(agent: Agent) -> &'static str {
+ match agent {
+ Agent::Pi => "pi",
+ Agent::Claude => "claude",
+ Agent::Codex => "codex",
+ Agent::Gemini => "gemini",
+ Agent::Cursor => "cursor",
+ Agent::Cline => "cline",
+ Agent::OpenCode => "opencode",
+ Agent::GithubCopilot => "copilot",
+ Agent::Kimi => "kimi",
+ Agent::Droid => "droid",
+ Agent::Amp => "amp",
+ }
+}
+
+pub fn parse_agent_label(agent: &str) -> Option<Agent> {
+ let name = agent.trim().to_lowercase();
+ match name.as_str() {
+ "pi" => Some(Agent::Pi),
+ "claude" | "claude-code" => Some(Agent::Claude),
+ "codex" => Some(Agent::Codex),
+ "gemini" => Some(Agent::Gemini),
+ "cursor" => Some(Agent::Cursor),
+ "cline" => Some(Agent::Cline),
+ "opencode" | "open-code" => Some(Agent::OpenCode),
+ "copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
+ "kimi" => Some(Agent::Kimi),
+ "droid" => Some(Agent::Droid),
+ "amp" | "amp-local" => Some(Agent::Amp),
+ _ => None,
+ }
+}
+
/// Identify which agent is running from the process name.
/// Returns `None` for plain shells or unrecognized programs.
pub fn identify_agent(process_name: &str) -> Option<Agent> {
@@ -624,6 +658,25 @@ mod tests {
}
#[test]
+ fn parse_known_agent_labels() {
+ assert_eq!(parse_agent_label("pi"), Some(Agent::Pi));
+ assert_eq!(parse_agent_label("claude"), Some(Agent::Claude));
+ assert_eq!(parse_agent_label("copilot"), Some(Agent::GithubCopilot));
+ assert_eq!(
+ parse_agent_label("github-copilot"),
+ Some(Agent::GithubCopilot)
+ );
+ assert_eq!(parse_agent_label("amp-local"), Some(Agent::Amp));
+ }
+
+ #[test]
+ fn agent_labels_use_display_names() {
+ assert_eq!(agent_label(Agent::Pi), "pi");
+ assert_eq!(agent_label(Agent::GithubCopilot), "copilot");
+ assert_eq!(agent_label(Agent::OpenCode), "opencode");
+ }
+
+ #[test]
fn identify_unknown_processes() {
assert_eq!(identify_agent("bash"), None);
assert_eq!(identify_agent("zsh"), None);
diff --git a/src/events.rs b/src/events.rs
index 52efef61..dd433d55 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -21,7 +21,7 @@ pub enum AppEvent {
HookStateReported {
pane_id: PaneId,
source: String,
- agent: Agent,
+ agent_label: String,
state: AgentState,
message: Option<String>,
},
@@ -34,7 +34,8 @@ pub enum AppEvent {
HookAgentReleased {
pane_id: PaneId,
source: String,
- agent: Agent,
+ agent_label: String,
+ known_agent: Option<Agent>,
},
/// A new version was downloaded and installed. Restart to use it.
UpdateReady { version: String },
diff --git a/src/pane.rs b/src/pane.rs
index 5e71e6c9..fb34b6ab 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -105,16 +105,18 @@ fn stabilize_agent_state(
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookAuthority {
pub source: String,
- pub agent: Agent,
+ pub agent_label: String,
pub state: AgentState,
pub message: Option<String>,
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectiveStateChange {
- pub previous_agent: Option<Agent>,
+ pub previous_agent_label: Option<String>,
+ pub previous_known_agent: Option<Agent>,
pub previous_state: AgentState,
- pub agent: Option<Agent>,
+ pub agent_label: Option<String>,
+ pub known_agent: Option<Agent>,
pub state: AgentState,
}
@@ -146,40 +148,36 @@ impl PaneState {
agent: Option<Agent>,
fallback_state: AgentState,
) -> Option<EffectiveStateChange> {
- let previous_agent = self.detected_agent;
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
self.detected_agent = agent;
self.fallback_state = fallback_state;
- if self
- .hook_authority
- .as_ref()
- .is_some_and(|authority| Some(authority.agent) != self.detected_agent)
- {
- self.hook_authority = None;
- }
- self.recompute_effective_state(previous_agent, previous_state)
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
}
pub fn set_hook_authority(
&mut self,
source: String,
- agent: Agent,
+ agent_label: String,
state: AgentState,
message: Option<String>,
) -> Option<EffectiveStateChange> {
- let previous_agent = self.detected_agent;
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
self.hook_authority = Some(HookAuthority {
source,
- agent,
+ agent_label,
state,
message,
});
- self.recompute_effective_state(previous_agent, previous_state)
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
}
pub fn clear_hook_authority(&mut self, source: Option<&str>) -> Option<EffectiveStateChange> {
- let previous_agent = self.detected_agent;
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
let should_clear = self
.hook_authority
@@ -189,51 +187,75 @@ impl PaneState {
return None;
}
self.hook_authority = None;
- self.recompute_effective_state(previous_agent, previous_state)
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
}
- pub fn release_agent(&mut self, source: &str, agent: Agent) -> Option<EffectiveStateChange> {
- if self.detected_agent != Some(agent) {
+ pub fn release_agent(
+ &mut self,
+ source: &str,
+ agent_label: &str,
+ ) -> Option<EffectiveStateChange> {
+ let Some(current_agent_label) = self.effective_agent_label() else {
+ return None;
+ };
+ if current_agent_label != agent_label {
return None;
}
- if self
- .hook_authority
- .as_ref()
- .is_some_and(|authority| authority.agent != agent || authority.source != source)
- {
+ if self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.agent_label != agent_label || authority.source != source
+ }) {
return None;
}
- let previous_agent = self.detected_agent;
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
self.detected_agent = None;
self.fallback_state = AgentState::Unknown;
self.hook_authority = None;
- self.recompute_effective_state(previous_agent, previous_state)
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ }
+
+ pub fn effective_agent_label(&self) -> Option<&str> {
+ self.hook_authority
+ .as_ref()
+ .map(|authority| authority.agent_label.as_str())
+ .or_else(|| self.detected_agent.map(crate::detect::agent_label))
+ }
+
+ pub fn effective_known_agent(&self) -> Option<Agent> {
+ if let Some(authority) = &self.hook_authority {
+ return crate::detect::parse_agent_label(&authority.agent_label);
+ }
+ self.detected_agent
}
fn recompute_effective_state(
&mut self,
- previous_agent: Option<Agent>,
+ previous_agent_label: Option<String>,
+ previous_known_agent: Option<Agent>,
previous_state: AgentState,
) -> Option<EffectiveStateChange> {
let state = self
.hook_authority
.as_ref()
- .filter(|authority| Some(authority.agent) == self.detected_agent)
.map(|authority| authority.state)
.unwrap_or(self.fallback_state);
+ let agent_label = self.effective_agent_label().map(str::to_string);
+ let known_agent = self.effective_known_agent();
- if previous_agent == self.detected_agent && previous_state == state {
+ if previous_agent_label == agent_label && previous_state == state {
return None;
}
self.state = state;
Some(EffectiveStateChange {
- previous_agent,
+ previous_agent_label,
+ previous_known_agent,
previous_state,
- agent: self.detected_agent,
+ agent_label,
+ known_agent,
state,
})
}
@@ -3312,33 +3334,57 @@ mod tests {
fn hook_authority_overrides_fallback_for_same_agent() {
let mut pane = PaneState::new();
pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority("herdr:pi".into(), Agent::Pi, AgentState::Working, None);
+ pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None);
assert_eq!(pane.detected_agent, Some(Agent::Pi));
assert_eq!(pane.fallback_state, AgentState::Idle);
+ assert_eq!(pane.effective_agent_label(), Some("pi"));
assert_eq!(pane.state, AgentState::Working);
}
#[test]
- fn hook_authority_clears_when_detected_agent_changes() {
+ fn hook_authority_can_override_with_unknown_agent_label() {
let mut pane = PaneState::new();
pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority("herdr:pi".into(), Agent::Pi, AgentState::Working, None);
+ pane.set_hook_authority(
+ "herdr:custom".into(),
+ "hermes".into(),
+ AgentState::Working,
+ None,
+ );
+
+ assert_eq!(pane.detected_agent, Some(Agent::Pi));
+ assert_eq!(pane.effective_agent_label(), Some("hermes"));
+ assert_eq!(pane.effective_known_agent(), None);
+ assert_eq!(pane.state, AgentState::Working);
+ }
+
+ #[test]
+ fn hook_authority_survives_detected_agent_changes() {
+ let mut pane = PaneState::new();
+ pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ pane.set_hook_authority(
+ "herdr:custom".into(),
+ "hermes".into(),
+ AgentState::Working,
+ None,
+ );
pane.set_detected_state(None, AgentState::Unknown);
- assert!(pane.hook_authority.is_none());
+ assert!(pane.hook_authority.is_some());
assert_eq!(pane.detected_agent, None);
- assert_eq!(pane.state, AgentState::Unknown);
+ assert_eq!(pane.effective_agent_label(), Some("hermes"));
+ assert_eq!(pane.state, AgentState::Working);
}
#[test]
fn release_agent_clears_identity_immediately() {
let mut pane = PaneState::new();
pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority("herdr:pi".into(), Agent::Pi, AgentState::Working, None);
+ pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None);
- pane.release_agent("herdr:pi", Agent::Pi);
+ pane.release_agent("herdr:pi", "pi");
assert!(pane.hook_authority.is_none());
assert_eq!(pane.detected_agent, None);
diff --git a/src/workspace.rs b/src/workspace.rs
index 23d9a8e1..30e968fe 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -289,20 +289,17 @@ impl Tab {
.pane_ids()
.iter()
.filter_map(|id| {
- let pane = self.panes.get(id);
- let agent = pane.and_then(|p| p.detected_agent)?;
- let state = pane.map(|p| p.state).unwrap_or(AgentState::Unknown);
- let seen = pane.map(|p| p.seen).unwrap_or(true);
- let agent_label = agent_name(agent).to_string();
+ let pane = self.panes.get(id)?;
+ let agent_label = pane.effective_agent_label()?.to_string();
Some(PaneDetail {
pane_id: *id,
tab_idx: self.number.saturating_sub(1),
tab_label: self.display_name(),
label: agent_label.clone(),
agent_label,
- agent: Some(agent),
- state,
- seen,
+ agent: pane.effective_known_agent(),
+ state: pane.state,
+ seen: pane.seen,
})
})
.collect()
@@ -740,22 +737,6 @@ fn pane_attention_priority(state: AgentState, seen: bool) -> u8 {
}
}
-fn agent_name(agent: Agent) -> &'static str {
- match agent {
- Agent::Pi => "pi",
- Agent::Claude => "claude",
- Agent::Codex => "codex",
- Agent::Gemini => "gemini",
- Agent::Cursor => "cursor",
- Agent::Cline => "cline",
- Agent::OpenCode => "opencode",
- Agent::GithubCopilot => "copilot",
- Agent::Kimi => "kimi",
- Agent::Droid => "droid",
- Agent::Amp => "amp",
- }
-}
-
pub fn derive_label_from_cwd(cwd: &Path) -> String {
if let Some(repo_root) = git_repo_root(cwd) {
if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) {
@@ -1091,4 +1072,25 @@ mod tests {
assert!(details.iter().any(|detail| detail.label == "main·pi"));
assert!(details.iter().any(|detail| detail.label == "logs·claude"));
}
+
+ #[test]
+ fn pane_details_include_hook_reported_unknown_agents() {
+ let mut ws = Workspace::test_new("test");
+ let root_pane = ws.tabs[0].root_pane;
+ ws.tabs[0]
+ .panes
+ .get_mut(&root_pane)
+ .unwrap()
+ .set_hook_authority(
+ "custom:hermes".into(),
+ "hermes".into(),
+ AgentState::Working,
+ None,
+ );
+
+ let details = ws.pane_details();
+ assert_eq!(details.len(), 1);
+ assert_eq!(details[0].agent_label, "hermes");
+ assert_eq!(details[0].agent, None);
+ }
}
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index ea4a9ef1..acd7f1b8 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -957,6 +957,51 @@ fn pane_report_agent_updates_effective_state() {
#[cfg(not(target_os = "macos"))]
#[test]
+fn pane_report_agent_accepts_unknown_agent_labels() {
+ 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":"req_hook_generic_1","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let pane_id = created["result"]["workspace"]["workspace_id"]
+ .as_str()
+ .map(|workspace_id| format!("{}-1", workspace_id))
+ .unwrap();
+
+ let hook = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_hook_generic_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:hermes","agent":"hermes","state":"working"}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(hook["result"]["type"], "ok");
+
+ let pane = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"req_hook_generic_3","method":"pane.get","params":{{"pane_id":"{}"}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(pane["result"]["pane"]["agent"], "hermes");
+ assert_eq!(pane["result"]["pane"]["agent_status"], "working");
+
+ cleanup_spawned_herdr(child, base);
+}
+
+#[cfg(not(target_os = "macos"))]
+#[test]
fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
let _lock = test_lock();
let base = unique_test_dir();