aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-01 19:30:58 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-01 19:32:26 +0300
commitca4270b31fe93c180f7321cb4006cb5e7307d889 (patch)
tree72628e311a1b8e5ebf78c74cbc61d990253dc4c4 /src
parentfb0c9717cfa961a47d63272e622e9e35a001082a (diff)
feat: add authoritative pi hook integration
Diffstat (limited to 'src')
-rw-r--r--src/api/schema.rs79
-rw-r--r--src/app/actions.rs287
-rw-r--r--src/app/mod.rs231
-rw-r--r--src/cli.rs45
-rw-r--r--src/events.rs21
-rw-r--r--src/integration/assets/pi/herdr-agent-state.ts125
-rw-r--r--src/integration/mod.rs86
-rw-r--r--src/main.rs6
-rw-r--r--src/pane.rs235
9 files changed, 993 insertions, 122 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 0b807eed..a187a889 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -36,6 +36,12 @@ pub enum Method {
PaneSendKeys(PaneSendKeysParams),
#[serde(rename = "pane.read")]
PaneRead(PaneReadParams),
+ #[serde(rename = "pane.report_agent")]
+ PaneReportAgent(PaneReportAgentParams),
+ #[serde(rename = "pane.clear_agent_authority")]
+ PaneClearAgentAuthority(PaneClearAgentAuthorityParams),
+ #[serde(rename = "pane.release_agent")]
+ PaneReleaseAgent(PaneReleaseAgentParams),
#[serde(rename = "pane.close")]
PaneClose(PaneTarget),
#[serde(rename = "events.subscribe")]
@@ -124,6 +130,30 @@ pub struct PaneReadParams {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReportAgentParams {
+ pub pane_id: String,
+ pub source: String,
+ pub agent: String,
+ pub state: PaneAgentState,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub message: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneClearAgentAuthorityParams {
+ pub pane_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PaneReleaseAgentParams {
+ pub pane_id: String,
+ pub source: String,
+ pub agent: String,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReadSource {
Visible,
@@ -476,6 +506,55 @@ mod tests {
}
#[test]
+ fn request_round_trips_for_pane_report_agent() {
+ let request = Request {
+ id: "req_hook".into(),
+ method: Method::PaneReportAgent(PaneReportAgentParams {
+ pane_id: "1-1".into(),
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ state: PaneAgentState::Working,
+ message: Some("thinking".into()),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
+ fn request_round_trips_for_pane_clear_agent_authority() {
+ let request = Request {
+ id: "req_clear".into(),
+ method: Method::PaneClearAgentAuthority(PaneClearAgentAuthorityParams {
+ pane_id: "1-1".into(),
+ source: Some("herdr:pi".into()),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
+ fn request_round_trips_for_pane_release_agent() {
+ let request = Request {
+ id: "req_release".into(),
+ method: Method::PaneReleaseAgent(PaneReleaseAgentParams {
+ pane_id: "1-1".into(),
+ source: "herdr:pi".into(),
+ agent: "pi".into(),
+ }),
+ };
+
+ let json = serde_json::to_string(&request).unwrap();
+ let restored: Request = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
fn request_uses_dot_method_names() {
let request = Request {
id: "req_1".into(),
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 02c25d64..04ff19ac 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -3,9 +3,10 @@
use tracing::{info, warn};
-use crate::detect::AgentState;
+use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::{find_in_direction, NavDirection, PaneId};
+use crate::pane::EffectiveStateChange;
use super::state::{AppState, Mode, ToastKind, ToastNotification};
@@ -43,22 +44,32 @@ fn notification_toast_for_state_change(
}
}
-fn agent_label(agent: crate::detect::Agent) -> &'static str {
+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",
+ 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",
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PaneStateUpdate {
+ pub pane_id: PaneId,
+ pub ws_idx: usize,
+ pub previous_agent: Option<Agent>,
+ pub previous_state: AgentState,
+ pub agent: Option<Agent>,
+ pub state: AgentState,
+}
+
// ---------------------------------------------------------------------------
// Workspace operations
// ---------------------------------------------------------------------------
@@ -193,9 +204,12 @@ impl AppState {
// ---------------------------------------------------------------------------
impl AppState {
- pub fn handle_app_event(&mut self, event: AppEvent) {
+ pub fn handle_app_event(&mut self, event: AppEvent) -> Vec<PaneStateUpdate> {
match event {
- AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id),
+ AppEvent::PaneDied { pane_id } => {
+ self.handle_pane_died(pane_id);
+ Vec::new()
+ }
AppEvent::UpdateReady { version } => {
self.update_available = Some(version.clone());
self.update_dismissed = true;
@@ -204,62 +218,115 @@ impl AppState {
title: format!("updated to v{version}"),
context: "restart to use it".to_string(),
});
+ Vec::new()
}
AppEvent::StateChanged {
pane_id,
agent,
state,
- } => {
- for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() {
- let workspace_name = ws.display_name();
- if let Some(pane) = ws.panes.get_mut(&pane_id) {
- let is_active_ws = self.active == Some(ws_idx);
- let prev_state = pane.state;
-
- // Mark unseen when transitioning to Idle in background
- if state == AgentState::Idle
- && prev_state != AgentState::Idle
- && !is_active_ws
- {
- pane.seen = false;
- }
-
- // Blocked prompts should always make noise; done sounds stay background-only.
- if self.sound.allows(agent) {
- if let Some(sound) =
- notification_sound_for_state_change(is_active_ws, prev_state, state)
- {
- crate::sound::play(sound);
- }
- }
-
- if self.toast_config.enabled {
- if let (Some(agent), Some(kind)) = (
- agent,
- notification_toast_for_state_change(
- is_active_ws,
- prev_state,
- state,
- ),
- ) {
- let event_text = match kind {
- ToastKind::NeedsAttention => "needs attention",
- ToastKind::Finished => "finished",
- ToastKind::UpdateInstalled => "updated",
- };
- self.toast = Some(ToastNotification {
- kind,
- title: format!("{} {}", agent_label(agent), event_text),
- context: format!("{} · {}", workspace_name, ws_idx + 1),
- });
- }
- }
-
- pane.detected_agent = agent;
- pane.state = state;
- break;
- }
- }
+ } => self
+ .update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state))
+ .into_iter()
+ .collect(),
+ AppEvent::HookStateReported {
+ pane_id,
+ source,
+ agent,
+ state,
+ message,
+ } => self
+ .update_pane_state(pane_id, |pane| {
+ pane.set_hook_authority(source, agent, state, message)
+ })
+ .into_iter()
+ .collect(),
+ AppEvent::HookAuthorityCleared { pane_id, source } => self
+ .update_pane_state(pane_id, |pane| pane.clear_hook_authority(source.as_deref()))
+ .into_iter()
+ .collect(),
+ AppEvent::HookAgentReleased {
+ pane_id,
+ source,
+ agent,
+ } => self
+ .update_pane_state(pane_id, |pane| pane.release_agent(&source, agent))
+ .into_iter()
+ .collect(),
+ }
+ }
+
+ fn update_pane_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate>
+ where
+ F: FnOnce(&mut crate::pane::PaneState) -> Option<EffectiveStateChange>,
+ {
+ let ws_idx = self
+ .workspaces
+ .iter()
+ .position(|ws| ws.panes.contains_key(&pane_id))?;
+ let workspace_name = self.workspaces[ws_idx].display_name();
+ let change = {
+ let pane = self.workspaces[ws_idx].panes.get_mut(&pane_id)?;
+ update(pane)?
+ };
+ self.apply_pane_state_change(ws_idx, pane_id, &workspace_name, change);
+ Some(PaneStateUpdate {
+ pane_id,
+ ws_idx,
+ previous_agent: change.previous_agent,
+ previous_state: change.previous_state,
+ agent: change.agent,
+ state: change.state,
+ })
+ }
+
+ fn apply_pane_state_change(
+ &mut self,
+ ws_idx: usize,
+ pane_id: PaneId,
+ workspace_name: &str,
+ change: EffectiveStateChange,
+ ) {
+ let is_active_ws = self.active == Some(ws_idx);
+ let Some(pane) = self.workspaces[ws_idx].panes.get_mut(&pane_id) else {
+ return;
+ };
+
+ if change.state == AgentState::Idle
+ && change.previous_state != AgentState::Idle
+ && !is_active_ws
+ {
+ pane.seen = false;
+ }
+
+ if self.sound.allows(change.agent) {
+ if let Some(sound) = notification_sound_for_state_change(
+ is_active_ws,
+ change.previous_state,
+ change.state,
+ ) {
+ crate::sound::play(sound);
+ }
+ }
+
+ if self.toast_config.enabled {
+ if let (Some(agent), Some(kind)) = (
+ change.agent,
+ notification_toast_for_state_change(
+ is_active_ws,
+ change.previous_state,
+ change.state,
+ ),
+ ) {
+ let event_text = match kind {
+ ToastKind::NeedsAttention => "needs attention",
+ ToastKind::Finished => "finished",
+ ToastKind::UpdateInstalled => "updated",
+ };
+ self.toast = Some(ToastNotification {
+ kind,
+ title: format!("{} {}", agent_label(agent), event_text),
+ context: format!("{} · {}", workspace_name, ws_idx + 1),
+ });
}
}
}
@@ -474,6 +541,94 @@ mod tests {
}
#[test]
+ fn hook_authority_ignores_fallback_state_changes() {
+ let mut state = app_with_workspaces(&["test"]);
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Pi),
+ state: AgentState::Idle,
+ });
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent: Agent::Pi,
+ state: AgentState::Working,
+ message: None,
+ });
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Pi),
+ state: AgentState::Idle,
+ });
+
+ let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
+ assert_eq!(pane.fallback_state, AgentState::Idle);
+ assert_eq!(pane.state, AgentState::Working);
+ assert!(pane.hook_authority.is_some());
+ }
+
+ #[test]
+ fn hook_authority_clears_when_agent_disappears() {
+ let mut state = app_with_workspaces(&["test"]);
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Pi),
+ state: AgentState::Idle,
+ });
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent: Agent::Pi,
+ state: AgentState::Working,
+ message: None,
+ });
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: None,
+ state: AgentState::Unknown,
+ });
+
+ let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
+ assert!(pane.hook_authority.is_none());
+ assert_eq!(pane.detected_agent, None);
+ assert_eq!(pane.state, AgentState::Unknown);
+ }
+
+ #[test]
+ fn hook_agent_release_clears_identity_immediately() {
+ let mut state = app_with_workspaces(&["test"]);
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Pi),
+ state: AgentState::Idle,
+ });
+ state.handle_app_event(AppEvent::HookStateReported {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent: Agent::Pi,
+ state: AgentState::Working,
+ message: None,
+ });
+ state.handle_app_event(AppEvent::HookAgentReleased {
+ pane_id,
+ source: "herdr:pi".into(),
+ agent: Agent::Pi,
+ });
+
+ let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
+ assert!(pane.hook_authority.is_none());
+ assert_eq!(pane.detected_agent, None);
+ assert_eq!(pane.fallback_state, AgentState::Unknown);
+ assert_eq!(pane.state, AgentState::Unknown);
+ }
+
+ #[test]
fn waiting_sound_plays_even_in_active_workspace() {
assert_eq!(
notification_sound_for_state_change(true, AgentState::Working, AgentState::Blocked),
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 7c670586..f6eb9af3 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -91,6 +91,9 @@ fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool {
| Method::WorkspaceRename(_)
| Method::WorkspaceClose(_)
| Method::PaneSplit(_)
+ | Method::PaneReportAgent(_)
+ | Method::PaneClearAgentAuthority(_)
+ | Method::PaneReleaseAgent(_)
| Method::PaneClose(_)
)
}
@@ -588,56 +591,76 @@ impl App {
}
fn handle_internal_event(&mut self, ev: AppEvent) {
- match &ev {
- AppEvent::PaneDied { pane_id } => {
- if let Some((ws_idx, _)) = self.find_pane(*pane_id) {
- if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) {
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneExited,
- data: crate::api::schema::EventData::PaneExited {
- pane_id: public_pane_id,
- workspace_id: self.public_workspace_id(ws_idx),
- },
- });
- }
+ if let AppEvent::PaneDied { pane_id } = &ev {
+ if let Some((ws_idx, _)) = self.find_pane(*pane_id) {
+ if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) {
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneExited,
+ data: crate::api::schema::EventData::PaneExited {
+ pane_id: public_pane_id,
+ workspace_id: self.public_workspace_id(ws_idx),
+ },
+ });
}
}
- AppEvent::StateChanged {
- pane_id,
- agent,
- state,
- } => {
- if let Some((ws_idx, pane)) = self.find_pane(*pane_id) {
- if let Some(pane_id) = self.public_pane_id(ws_idx, *pane_id) {
- let workspace_id = self.public_workspace_id(ws_idx);
- if pane.detected_agent != *agent {
- 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: agent.map(agent_name),
- },
- });
- }
- if pane.state != *state {
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneAgentStateChanged,
- data: crate::api::schema::EventData::PaneAgentStateChanged {
- pane_id,
- workspace_id,
- state: pane_agent_state(*state),
- },
- });
- }
+ }
+
+ let released_agent = if let AppEvent::HookAgentReleased { pane_id, agent, .. } = &ev {
+ Some((*pane_id, *agent))
+ } else {
+ None
+ };
+
+ let previous_toast = self.state.toast.clone();
+ let pane_updates = self.state.handle_app_event(ev);
+ for update in &pane_updates {
+ self.emit_pane_state_update(update);
+ }
+ if let Some((pane_id, agent)) = released_agent {
+ if pane_updates.iter().any(|update| update.pane_id == pane_id) {
+ if let Some((ws_idx, _)) = self.find_pane(pane_id) {
+ if let Some(runtime) = self.state.workspaces[ws_idx].runtimes.get(&pane_id) {
+ runtime.begin_graceful_release(agent);
}
}
}
- AppEvent::UpdateReady { .. } => {}
}
+ self.sync_toast_deadline(previous_toast);
+ }
- let previous_toast = self.state.toast.clone();
- self.state.handle_app_event(ev);
+ fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) {
+ let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else {
+ return;
+ };
+ let workspace_id = self.public_workspace_id(update.ws_idx);
+
+ if update.previous_agent != update.agent {
+ 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),
+ },
+ });
+ }
+
+ if update.previous_state != update.state {
+ self.emit_event(crate::api::schema::EventEnvelope {
+ event: crate::api::schema::EventKind::PaneAgentStateChanged,
+ data: crate::api::schema::EventData::PaneAgentStateChanged {
+ pane_id,
+ workspace_id,
+ state: pane_agent_state(update.state),
+ },
+ });
+ }
+ }
+
+ fn sync_toast_deadline(
+ &mut self,
+ previous_toast: Option<crate::app::state::ToastNotification>,
+ ) {
if self.state.toast != previous_toast {
self.toast_deadline = self.state.toast.as_ref().map(|toast| {
let duration = match toast.kind {
@@ -716,10 +739,14 @@ impl App {
fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> {
if let Some(rest) = id.strip_prefix("p_") {
- let (ws_raw, pane_raw) = rest.split_once('_')?;
- let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?;
- let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?);
- return Some((ws_idx, pane_id));
+ if let Some((ws_raw, pane_raw)) = rest.split_once('_') {
+ let ws_idx = ws_raw.parse::<usize>().ok()?.checked_sub(1)?;
+ let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?);
+ return Some((ws_idx, pane_id));
+ }
+
+ let pane_id = crate::layout::PaneId::from_raw(rest.parse::<u32>().ok()?);
+ return self.find_pane(pane_id).map(|(ws_idx, _)| (ws_idx, pane_id));
}
let (ws_raw, pane_number_raw) = id.split_once('-')?;
@@ -1064,6 +1091,90 @@ impl App {
},
}
}
+ Method::PaneReportAgent(params) => {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: format!("pane {} not found", params.pane_id),
+ },
+ })
+ .unwrap();
+ };
+ let Some(agent) = parse_agent_name(&params.agent) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "invalid_agent".into(),
+ message: format!("unsupported agent {}", params.agent),
+ },
+ })
+ .unwrap();
+ };
+ self.handle_internal_event(crate::events::AppEvent::HookStateReported {
+ pane_id,
+ source: params.source,
+ agent,
+ state: detect_state_from_api(params.state),
+ message: params.message,
+ });
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
+ Method::PaneClearAgentAuthority(params) => {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: format!("pane {} not found", params.pane_id),
+ },
+ })
+ .unwrap();
+ };
+ self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared {
+ pane_id,
+ source: params.source,
+ });
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
+ Method::PaneReleaseAgent(params) => {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "pane_not_found".into(),
+ message: format!("pane {} not found", params.pane_id),
+ },
+ })
+ .unwrap();
+ };
+ let Some(agent) = parse_agent_name(&params.agent) else {
+ return serde_json::to_string(&ErrorResponse {
+ id: request.id,
+ error: ErrorBody {
+ code: "invalid_agent".into(),
+ message: format!("unsupported agent {}", params.agent),
+ },
+ })
+ .unwrap();
+ };
+ self.handle_internal_event(crate::events::AppEvent::HookAgentReleased {
+ pane_id,
+ source: params.source,
+ agent,
+ });
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
Method::PaneSendText(params) => {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return serde_json::to_string(&ErrorResponse {
@@ -1483,6 +1594,15 @@ fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> {
}
}
+fn detect_state_from_api(state: crate::api::schema::PaneAgentState) -> crate::detect::AgentState {
+ match state {
+ crate::api::schema::PaneAgentState::Idle => crate::detect::AgentState::Idle,
+ crate::api::schema::PaneAgentState::Working => crate::detect::AgentState::Working,
+ crate::api::schema::PaneAgentState::Blocked => crate::detect::AgentState::Blocked,
+ crate::api::schema::PaneAgentState::Unknown => crate::detect::AgentState::Unknown,
+ }
+}
+
fn pane_agent_state(state: crate::detect::AgentState) -> crate::api::schema::PaneAgentState {
match state {
crate::detect::AgentState::Idle => crate::api::schema::PaneAgentState::Idle,
@@ -1492,6 +1612,23 @@ fn pane_agent_state(state: crate::detect::AgentState) -> crate::api::schema::Pan
}
}
+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",
diff --git a/src/cli.rs b/src/cli.rs
index bbcd1abf..3f45c2ed 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -26,6 +26,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
"workspace" => run_workspace_command(&args[2..])?,
"pane" => run_pane_command(&args[2..])?,
"wait" => run_wait_command(&args[2..])?,
+ "integration" => run_integration_command(&args[2..])?,
_ => return Ok(CommandOutcome::NotCli),
};
@@ -90,6 +91,21 @@ fn run_wait_command(args: &[String]) -> std::io::Result<i32> {
}
}
+fn run_integration_command(args: &[String]) -> std::io::Result<i32> {
+ let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
+ print_integration_help();
+ return Ok(2);
+ };
+
+ match subcommand {
+ "install" => integration_install(&args[1..]),
+ _ => {
+ print_integration_help();
+ Ok(2)
+ }
+ }
+}
+
fn workspace_list(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr workspace list");
@@ -432,6 +448,30 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> {
}))
}
+fn integration_install(args: &[String]) -> std::io::Result<i32> {
+ let Some(target) = args.first().map(|arg| arg.as_str()) else {
+ eprintln!("usage: herdr integration install <pi>");
+ return Ok(2);
+ };
+ if args.len() != 1 {
+ eprintln!("usage: herdr integration install <pi>");
+ return Ok(2);
+ }
+
+ match target {
+ "pi" => {
+ let path = crate::integration::install_pi()?;
+ println!("installed pi integration to {}", path.display());
+ Ok(0)
+ }
+ _ => {
+ eprintln!("unknown integration target: {target}");
+ eprintln!("currently supported: pi");
+ Ok(2)
+ }
+ }
+}
+
fn wait_output(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
eprintln!("usage: herdr wait output <pane_id> --match <text> [--source visible|recent] [--lines N] [--timeout MS] [--regex]");
@@ -743,6 +783,11 @@ fn print_wait_help() {
);
}
+fn print_integration_help() {
+ eprintln!("herdr integration commands:");
+ eprintln!(" herdr integration install pi");
+}
+
fn _print_json<T: Serialize>(value: &T) {
println!("{}", serde_json::to_string(value).unwrap());
}
diff --git a/src/events.rs b/src/events.rs
index 58b16863..c1c45a0e 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -11,12 +11,31 @@ use crate::layout::PaneId;
pub enum AppEvent {
/// A pane's child process exited.
PaneDied { pane_id: PaneId },
- /// Agent state changed in a pane (detected by the PTY reader).
+ /// Fallback detector state changed in a pane.
StateChanged {
pane_id: PaneId,
agent: Option<Agent>,
state: AgentState,
},
+ /// Hook-authoritative agent state was reported for a pane.
+ HookStateReported {
+ pane_id: PaneId,
+ source: String,
+ agent: Agent,
+ state: AgentState,
+ message: Option<String>,
+ },
+ /// Hook authority was explicitly cleared for a pane.
+ HookAuthorityCleared {
+ pane_id: PaneId,
+ source: Option<String>,
+ },
+ /// The current detected agent gracefully released this pane back to the shell.
+ HookAgentReleased {
+ pane_id: PaneId,
+ source: String,
+ agent: Agent,
+ },
/// A new version was downloaded and installed. Restart to use it.
UpdateReady { version: String },
}
diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts
new file mode 100644
index 00000000..f51f05d1
--- /dev/null
+++ b/src/integration/assets/pi/herdr-agent-state.ts
@@ -0,0 +1,125 @@
+// installed by herdr
+// safe to edit. this integration only activates inside herdr-managed panes.
+// @ts-nocheck
+
+import { createConnection } from "node:net";
+
+const HERDR_ENV = process.env.HERDR_ENV;
+const socketPath = process.env.HERDR_SOCKET_PATH;
+const paneId = process.env.HERDR_PANE_ID;
+const source = "herdr:pi";
+
+function enabled() {
+ return HERDR_ENV === "1" && !!socketPath && !!paneId;
+}
+
+function sendRequest(request: unknown): Promise<void> {
+ if (!enabled()) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ socket.destroy();
+ resolve();
+ };
+
+ const socket = createConnection(socketPath!);
+ socket.on("error", finish);
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
+ socket.on("data", finish);
+ socket.on("end", finish);
+ const timeout = setTimeout(finish, 500);
+ timeout.unref?.();
+ });
+}
+
+function sendState(state: "working" | "blocked" | "idle", message?: string): Promise<void> {
+ return sendRequest({
+ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
+ method: "pane.report_agent",
+ params: {
+ pane_id: paneId,
+ source,
+ agent: "pi",
+ state,
+ message,
+ },
+ });
+}
+
+function releaseAgent(): Promise<void> {
+ return sendRequest({
+ id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
+ method: "pane.release_agent",
+ params: {
+ pane_id: paneId,
+ source,
+ agent: "pi",
+ },
+ });
+}
+
+export default function (pi) {
+ if (!enabled()) {
+ return;
+ }
+
+ let agentActive = false;
+ let blockedCount = 0;
+ let blockedMessage: string | undefined;
+ let lastState: "working" | "blocked" | "idle" | undefined;
+ let lastMessage: string | undefined;
+
+ function desiredState() {
+ if (blockedCount > 0) {
+ return { state: "blocked" as const, message: blockedMessage };
+ }
+ if (agentActive) {
+ return { state: "working" as const, message: undefined };
+ }
+ return { state: "idle" as const, message: undefined };
+ }
+
+ function publishState() {
+ const next = desiredState();
+ if (next.state === lastState && next.message === lastMessage) {
+ return;
+ }
+ lastState = next.state;
+ lastMessage = next.message;
+ void sendState(next.state, next.message);
+ }
+
+ pi.events.on("herdr:blocked", (data) => {
+ if (!data?.active) {
+ blockedCount = Math.max(0, blockedCount - 1);
+ if (blockedCount === 0) {
+ blockedMessage = undefined;
+ }
+ publishState();
+ return;
+ }
+
+ blockedCount += 1;
+ blockedMessage = data.label;
+ publishState();
+ });
+
+ pi.on("agent_start", () => {
+ agentActive = true;
+ publishState();
+ });
+
+ pi.on("agent_end", () => {
+ agentActive = false;
+ publishState();
+ });
+
+ pi.on("session_shutdown", async () => {
+ await releaseAgent();
+ });
+}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
new file mode 100644
index 00000000..65c5c6ad
--- /dev/null
+++ b/src/integration/mod.rs
@@ -0,0 +1,86 @@
+use std::fs;
+use std::io;
+use std::path::PathBuf;
+
+use portable_pty::CommandBuilder;
+
+use crate::layout::PaneId;
+
+pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID";
+const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
+const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
+
+pub(crate) fn apply_pane_env(cmd: &mut CommandBuilder, pane_id: PaneId) {
+ cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path());
+ cmd.env(HERDR_PANE_ID_ENV_VAR, format!("p_{}", pane_id.raw()));
+}
+
+pub(crate) fn install_pi() -> io::Result<PathBuf> {
+ let dir = pi_extension_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "pi extension directory not found at {}. install pi and create the extensions directory first",
+ dir.display()
+ )));
+ }
+
+ let path = dir.join(PI_EXTENSION_INSTALL_NAME);
+ fs::write(&path, PI_EXTENSION_ASSET)?;
+ Ok(path)
+}
+
+fn pi_extension_dir() -> io::Result<PathBuf> {
+ let home = std::env::var("HOME")
+ .map(PathBuf::from)
+ .map_err(|_| io::Error::other("HOME is not set; cannot locate ~/.pi/agent/extensions"))?;
+ Ok(home.join(".pi/agent/extensions"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn unique_base() -> PathBuf {
+ std::env::temp_dir().join(format!(
+ "herdr-integration-install-test-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ))
+ }
+
+ #[test]
+ fn install_pi_writes_embedded_asset_to_pi_extensions_dir() {
+ let base = unique_base();
+ let home = base.join("home");
+ let ext_dir = home.join(".pi/agent/extensions");
+ fs::create_dir_all(&ext_dir).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let path = install_pi().unwrap();
+ let content = fs::read_to_string(&path).unwrap();
+
+ assert_eq!(path, ext_dir.join(PI_EXTENSION_INSTALL_NAME));
+ assert_eq!(content, PI_EXTENSION_ASSET);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_pi_errors_when_extension_dir_missing() {
+ let base = unique_base();
+ let home = base.join("home");
+ fs::create_dir_all(&home).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let err = install_pi().unwrap_err().to_string();
+
+ assert!(err.contains("pi extension directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 981c4c70..ab841194 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -25,6 +25,7 @@ mod config;
mod detect;
mod events;
mod input;
+mod integration;
mod layout;
mod pane;
mod persist;
@@ -187,12 +188,14 @@ fn main() -> io::Result<()> {
println!(" herdr workspace <subcommand> ...");
println!(" herdr pane <subcommand> ...");
println!(" herdr wait <subcommand> ...");
+ println!(" herdr integration <subcommand> ...");
println!();
println!("Commands:");
println!(" update Download and install the latest version");
println!(" workspace workspace helpers over the socket api");
println!(" pane pane control helpers over the socket api");
println!(" wait blocking wait helpers over the socket api");
+ println!(" integration install built-in agent integrations");
println!();
println!("Options:");
println!(" --no-session Don't restore or save sessions");
@@ -233,7 +236,8 @@ fn main() -> io::Result<()> {
eprintln!("run 'herdr --help' for usage");
std::process::exit(1);
}
- if !arg.starts_with('-') && !["update", "workspace", "pane", "wait"].contains(&arg.as_str())
+ if !arg.starts_with('-')
+ && !["update", "workspace", "pane", "wait", "integration"].contains(&arg.as_str())
{
eprintln!("unknown command: {arg}");
eprintln!("run 'herdr --help' for usage");
diff --git a/src/pane.rs b/src/pane.rs
index 01d872a6..01270005 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -2,7 +2,7 @@ use std::cell::Cell;
use std::io::{BufWriter, Read, Write};
use std::sync::{
atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering},
- Arc, RwLock,
+ Arc, Mutex, RwLock,
};
use bytes::Bytes;
@@ -16,6 +16,28 @@ use crate::layout::PaneId;
use crate::pty_callbacks::PtyResponses;
const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200);
+const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1);
+
+#[derive(Debug, Clone, Copy)]
+struct PendingAgentRelease {
+ agent: Agent,
+ until: std::time::Instant,
+}
+
+fn active_pending_release(
+ pending_release: &Mutex<Option<PendingAgentRelease>>,
+ now: std::time::Instant,
+) -> Option<Agent> {
+ let mut pending_release = pending_release.lock().ok()?;
+ match *pending_release {
+ Some(pending) if now < pending.until => Some(pending.agent),
+ Some(_) => {
+ *pending_release = None;
+ None
+ }
+ None => None,
+ }
+}
fn stabilize_agent_state(
agent: Option<Agent>,
@@ -51,10 +73,28 @@ fn stabilize_agent_state(
// PaneState — pure data, constructable without PTYs, testable
// ---------------------------------------------------------------------------
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct HookAuthority {
+ pub source: String,
+ pub agent: Agent,
+ pub state: AgentState,
+ pub message: Option<String>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct EffectiveStateChange {
+ pub previous_agent: Option<Agent>,
+ pub previous_state: AgentState,
+ pub agent: Option<Agent>,
+ pub state: AgentState,
+}
+
/// Observable state for a single pane.
/// This is the only part of a pane that workspace logic and tests need.
pub struct PaneState {
pub detected_agent: Option<Agent>,
+ pub fallback_state: AgentState,
+ pub hook_authority: Option<HookAuthority>,
pub state: AgentState,
/// Whether the user has seen this pane since its last state change to Idle.
/// False = "Done" (agent finished while user was in another workspace).
@@ -65,10 +105,109 @@ impl PaneState {
pub fn new() -> Self {
Self {
detected_agent: None,
+ fallback_state: AgentState::Unknown,
+ hook_authority: None,
state: AgentState::Unknown,
seen: true,
}
}
+
+ pub fn set_detected_state(
+ &mut self,
+ agent: Option<Agent>,
+ fallback_state: AgentState,
+ ) -> Option<EffectiveStateChange> {
+ let previous_agent = self.detected_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)
+ }
+
+ pub fn set_hook_authority(
+ &mut self,
+ source: String,
+ agent: Agent,
+ state: AgentState,
+ message: Option<String>,
+ ) -> Option<EffectiveStateChange> {
+ let previous_agent = self.detected_agent;
+ let previous_state = self.state;
+ self.hook_authority = Some(HookAuthority {
+ source,
+ agent,
+ state,
+ message,
+ });
+ self.recompute_effective_state(previous_agent, previous_state)
+ }
+
+ pub fn clear_hook_authority(&mut self, source: Option<&str>) -> Option<EffectiveStateChange> {
+ let previous_agent = self.detected_agent;
+ let previous_state = self.state;
+ let should_clear = self
+ .hook_authority
+ .as_ref()
+ .is_some_and(|authority| source.is_none_or(|source| authority.source == source));
+ if !should_clear {
+ return None;
+ }
+ self.hook_authority = None;
+ self.recompute_effective_state(previous_agent, previous_state)
+ }
+
+ pub fn release_agent(&mut self, source: &str, agent: Agent) -> Option<EffectiveStateChange> {
+ if self.detected_agent != Some(agent) {
+ return None;
+ }
+
+ if self
+ .hook_authority
+ .as_ref()
+ .is_some_and(|authority| authority.agent != agent || authority.source != source)
+ {
+ return None;
+ }
+
+ let previous_agent = self.detected_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)
+ }
+
+ fn recompute_effective_state(
+ &mut self,
+ previous_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);
+
+ if previous_agent == self.detected_agent && previous_state == state {
+ return None;
+ }
+
+ self.state = state;
+ Some(EffectiveStateChange {
+ previous_agent,
+ previous_state,
+ agent: self.detected_agent,
+ state,
+ })
+ }
}
// ---------------------------------------------------------------------------
@@ -90,6 +229,8 @@ pub struct PaneRuntime {
/// Kept alive here so the Arc isn't dropped; tasks hold their own clones.
#[allow(dead_code)]
screen_content: Arc<RwLock<String>>,
+ detect_reset_notify: Arc<Notify>,
+ pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
// Task handles for deterministic shutdown
detect_handle: tokio::task::AbortHandle,
}
@@ -279,6 +420,7 @@ impl PaneRuntime {
let mut cmd = CommandBuilder::new(&shell);
cmd.cwd(cwd);
cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
+ crate::integration::apply_pane_env(&mut cmd, pane_id);
let reader = pair
.master
@@ -379,17 +521,22 @@ impl PaneRuntime {
}
// --- Detection task ---
- let detect_handle = {
+ let (detect_handle, detect_reset_notify, pending_release) = {
use crate::detect;
use std::time::{Duration, Instant};
const TICK_UNIDENTIFIED: Duration = Duration::from_millis(500);
const TICK_IDENTIFIED: Duration = Duration::from_millis(300);
+ const TICK_PENDING_RELEASE: Duration = Duration::from_millis(50);
const PROCESS_RECHECK: Duration = Duration::from_secs(5);
let child_pid = child_pid.clone();
let screen_content = screen_content.clone();
let state_events = events.clone();
+ let detect_reset_notify = Arc::new(Notify::new());
+ let detect_reset = detect_reset_notify.clone();
+ let pending_release = Arc::new(Mutex::new(None));
+ let pending_release_for_task = pending_release.clone();
let handle = tokio::spawn(async move {
let mut agent: Option<Agent> = None;
@@ -400,15 +547,28 @@ impl PaneRuntime {
tokio::time::sleep(Duration::from_millis(50)).await;
loop {
- let tick = if agent.is_none() {
+ let tick = if active_pending_release(&pending_release_for_task, Instant::now())
+ .is_some()
+ {
+ TICK_PENDING_RELEASE
+ } else if agent.is_none() {
TICK_UNIDENTIFIED
} else {
TICK_IDENTIFIED
};
- tokio::time::sleep(tick).await;
+ tokio::select! {
+ _ = tokio::time::sleep(tick) => {}
+ _ = detect_reset.notified() => {
+ agent = None;
+ state = AgentState::Unknown;
+ last_claude_working_at = None;
+ }
+ }
let now = Instant::now();
- let should_check_process = agent.is_none()
+ let suppressed_agent = active_pending_release(&pending_release_for_task, now);
+ let should_check_process = suppressed_agent.is_some()
+ || agent.is_none()
|| now.duration_since(last_process_check) >= PROCESS_RECHECK;
let mut agent_changed = false;
@@ -418,7 +578,18 @@ impl PaneRuntime {
if pid > 0 {
if let Some(job) = detect::foreground_job(pid) {
let identified = detect::identify_agent_in_job(&job);
- let new_agent = identified.as_ref().map(|(agent, _)| *agent);
+ let mut new_agent = identified.as_ref().map(|(agent, _)| *agent);
+
+ if let Some(suppressed_agent) = suppressed_agent {
+ if new_agent == Some(suppressed_agent) {
+ new_agent = None;
+ } else if let Ok(mut pending_release) =
+ pending_release_for_task.lock()
+ {
+ *pending_release = None;
+ }
+ }
+
if new_agent != agent {
if let Some((_, process_name)) = identified {
info!(
@@ -480,7 +651,7 @@ impl PaneRuntime {
}
}
});
- handle.abort_handle()
+ (handle.abort_handle(), detect_reset_notify, pending_release)
};
// --- Writer task: channel → PTY ---
@@ -530,10 +701,22 @@ impl PaneRuntime {
kitty_keyboard_flags,
mouse_alternate_scroll,
screen_content,
+ detect_reset_notify,
+ pending_release,
detect_handle,
})
}
+ pub fn begin_graceful_release(&self, agent: Agent) {
+ if let Ok(mut pending_release) = self.pending_release.lock() {
+ *pending_release = Some(PendingAgentRelease {
+ agent,
+ until: std::time::Instant::now() + RELEASE_REACQUIRE_SUPPRESSION,
+ });
+ }
+ self.detect_reset_notify.notify_one();
+ }
+
/// Resize if the dimensions actually changed.
pub fn resize(&self, rows: u16, cols: u16) {
let rows = rows.max(2);
@@ -766,4 +949,42 @@ mod tests {
);
assert_eq!(state, AgentState::Idle);
}
+
+ #[test]
+ 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);
+
+ assert_eq!(pane.detected_agent, Some(Agent::Pi));
+ assert_eq!(pane.fallback_state, AgentState::Idle);
+ assert_eq!(pane.state, AgentState::Working);
+ }
+
+ #[test]
+ fn hook_authority_clears_when_detected_agent_changes() {
+ 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_detected_state(None, AgentState::Unknown);
+
+ assert!(pane.hook_authority.is_none());
+ assert_eq!(pane.detected_agent, None);
+ assert_eq!(pane.state, AgentState::Unknown);
+ }
+
+ #[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.release_agent("herdr:pi", Agent::Pi);
+
+ assert!(pane.hook_authority.is_none());
+ assert_eq!(pane.detected_agent, None);
+ assert_eq!(pane.fallback_state, AgentState::Unknown);
+ assert_eq!(pane.state, AgentState::Unknown);
+ }
}