diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-01 19:30:58 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-01 19:32:26 +0300 |
| commit | ca4270b31fe93c180f7321cb4006cb5e7307d889 (patch) | |
| tree | 72628e311a1b8e5ebf78c74cbc61d990253dc4c4 /src | |
| parent | fb0c9717cfa961a47d63272e622e9e35a001082a (diff) | |
feat: add authoritative pi hook integration
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/schema.rs | 79 | ||||
| -rw-r--r-- | src/app/actions.rs | 287 | ||||
| -rw-r--r-- | src/app/mod.rs | 231 | ||||
| -rw-r--r-- | src/cli.rs | 45 | ||||
| -rw-r--r-- | src/events.rs | 21 | ||||
| -rw-r--r-- | src/integration/assets/pi/herdr-agent-state.ts | 125 | ||||
| -rw-r--r-- | src/integration/mod.rs | 86 | ||||
| -rw-r--r-- | src/main.rs | 6 | ||||
| -rw-r--r-- | src/pane.rs | 235 |
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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.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", @@ -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); + } } |
