diff --git a/src/api/schema.rs b/src/api/schema.rs index e7534465..fb7177f7 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -201,6 +201,8 @@ pub struct PaneReportAgentParams { pub state: PaneAgentState, #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -208,6 +210,8 @@ pub struct PaneClearAgentAuthorityParams { pub pane_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -215,6 +219,8 @@ pub struct PaneReleaseAgentParams { pub pane_id: String, pub source: String, pub agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seq: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -717,6 +723,7 @@ mod tests { agent: "pi".into(), state: PaneAgentState::Working, message: Some("thinking".into()), + seq: Some(42), }), }; @@ -732,6 +739,7 @@ mod tests { method: Method::PaneClearAgentAuthority(PaneClearAgentAuthorityParams { pane_id: "1-1".into(), source: Some("herdr:pi".into()), + seq: Some(42), }), }; @@ -748,6 +756,7 @@ mod tests { pane_id: "1-1".into(), source: "herdr:pi".into(), agent: "pi".into(), + seq: Some(42), }), }; diff --git a/src/app/actions.rs b/src/app/actions.rs index b8777cdc..317bbff7 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -599,23 +599,33 @@ impl AppState { agent_label, state, message, + seq, } => self .update_pane_state(pane_id, |pane| { - pane.set_hook_authority(source, agent_label, state, message) + pane.set_hook_authority(source, agent_label, state, message, seq) }) .into_iter() .collect(), - AppEvent::HookAuthorityCleared { pane_id, source } => self - .update_pane_state(pane_id, |pane| pane.clear_hook_authority(source.as_deref())) + AppEvent::HookAuthorityCleared { + pane_id, + source, + seq, + } => self + .update_pane_state(pane_id, |pane| { + pane.clear_hook_authority(source.as_deref(), seq) + }) .into_iter() .collect(), AppEvent::HookAgentReleased { pane_id, source, agent_label, + seq, .. } => self - .update_pane_state(pane_id, |pane| pane.release_agent(&source, &agent_label)) + .update_pane_state(pane_id, |pane| { + pane.release_agent(&source, &agent_label, seq) + }) .into_iter() .collect(), // Intercepted in App::handle_internal_event before reaching this @@ -1153,6 +1163,7 @@ mod tests { agent_label: "hermes".into(), state: AgentState::Blocked, message: None, + seq: None, }); let toast = state.toast.as_ref().unwrap(); diff --git a/src/app/api.rs b/src/app/api.rs index b23787d2..c675aa6f 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1019,6 +1019,7 @@ impl App { agent_label, state: detect_state_from_api(params.state), message: params.message, + seq: params.seq, }); SuccessResponse { id: request.id, @@ -1039,6 +1040,7 @@ impl App { self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared { pane_id, source: params.source, + seq: params.seq, }); SuccessResponse { id: request.id, @@ -1071,6 +1073,7 @@ impl App { source: params.source, known_agent: crate::detect::parse_agent_label(&agent_label), agent_label, + seq: params.seq, }); SuccessResponse { id: request.id, diff --git a/src/cli.rs b/src/cli.rs index a5ba8b1a..ce50b9b5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -319,6 +319,7 @@ fn run_integration_command(args: &[String]) -> std::io::Result { match subcommand { "install" => integration_install(&args[1..]), "uninstall" => integration_uninstall(&args[1..]), + "status" => integration_status(&args[1..]), "help" | "--help" | "-h" => { print_integration_help(); Ok(0) @@ -983,6 +984,42 @@ fn pane_run(args: &[String]) -> std::io::Result { })) } +fn integration_status(args: &[String]) -> std::io::Result { + let outdated_only = match args { + [] => false, + [flag] if flag == "--outdated-only" => true, + _ => { + eprintln!("usage: herdr integration status [--outdated-only]"); + return Ok(2); + } + }; + + if outdated_only { + crate::integration::print_outdated_update_notice(); + return Ok(0); + } + + for status in crate::integration::installed_integration_statuses() { + let target = crate::integration::integration_target_label(status.target); + let version = match status.installed_version { + Some(version) => format!("v{version}"), + None => "legacy".to_string(), + }; + let state = match status.state { + crate::integration::IntegrationStatusKind::NotInstalled => "not installed".to_string(), + crate::integration::IntegrationStatusKind::Current => { + format!("current ({version})") + } + crate::integration::IntegrationStatusKind::Outdated => { + format!("outdated ({version} < v{})", status.expected_version) + } + }; + println!("{target}: {state} ({})", status.path.display()); + } + + Ok(0) +} + fn integration_install(args: &[String]) -> std::io::Result { let Some(target) = parse_integration_target(args, "install")? else { return Ok(2); @@ -1488,6 +1525,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall claude"); eprintln!(" herdr integration uninstall codex"); eprintln!(" herdr integration uninstall opencode"); + eprintln!(" herdr integration status [--outdated-only]"); } fn print_session_help() { diff --git a/src/events.rs b/src/events.rs index 74045d4c..8d1ff47a 100644 --- a/src/events.rs +++ b/src/events.rs @@ -25,11 +25,13 @@ pub enum AppEvent { agent_label: String, state: AgentState, message: Option, + seq: Option, }, /// Hook authority was explicitly cleared for a pane. HookAuthorityCleared { pane_id: PaneId, source: Option, + seq: Option, }, /// The current detected agent gracefully released this pane back to the shell. HookAgentReleased { @@ -37,6 +39,7 @@ pub enum AppEvent { source: String, agent_label: String, known_agent: Option, + seq: Option, }, /// A new version is available and ready to install explicitly. UpdateReady { version: String }, diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh index 527fe9f8..18b042e8 100644 --- a/src/integration/assets/claude/herdr-agent-state.sh +++ b/src/integration/assets/claude/herdr-agent-state.sh @@ -1,6 +1,8 @@ #!/bin/sh # installed by herdr # safe to edit. this hook only activates inside herdr-managed panes. +# HERDR_INTEGRATION_ID=claude +# HERDR_INTEGRATION_VERSION=1 set -eu @@ -50,6 +52,7 @@ if is_subagent and action in ("idle", "release"): action = "working" request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() if action == "release": request = { "id": request_id, @@ -58,6 +61,7 @@ if action == "release": "pane_id": pane_id, "source": source, "agent": "claude", + "seq": report_seq, }, } else: @@ -69,6 +73,7 @@ else: "source": source, "agent": "claude", "state": action, + "seq": report_seq, }, } diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh index 01510d6d..c6ee287c 100644 --- a/src/integration/assets/codex/herdr-agent-state.sh +++ b/src/integration/assets/codex/herdr-agent-state.sh @@ -1,6 +1,8 @@ #!/bin/sh # installed by herdr # safe to edit. this hook only activates inside herdr-managed panes. +# HERDR_INTEGRATION_ID=codex +# HERDR_INTEGRATION_VERSION=1 set -eu @@ -33,6 +35,7 @@ if not pane_id or not socket_path: raise SystemExit(0) request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() if action == "release": request = { "id": request_id, @@ -41,6 +44,7 @@ if action == "release": "pane_id": pane_id, "source": source, "agent": "codex", + "seq": report_seq, }, } else: @@ -52,6 +56,7 @@ else: "source": source, "agent": "codex", "state": action, + "seq": report_seq, }, } diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index 6d5e3c87..0fb96217 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -1,6 +1,17 @@ +// installed by herdr +// safe to edit. this plugin only activates inside herdr-managed panes. +// HERDR_INTEGRATION_ID=opencode +// HERDR_INTEGRATION_VERSION=1 + import net from "node:net"; const SOURCE = "herdr:opencode"; +let reportSeq = Date.now() * 1000; + +function nextReportSeq() { + reportSeq += 1; + return reportSeq; +} function reportState(action) { const paneId = process.env.HERDR_PANE_ID; @@ -22,12 +33,14 @@ function reportState(action) { pane_id: paneId, source: SOURCE, agent: "opencode", + seq: nextReportSeq(), } : { pane_id: paneId, source: SOURCE, agent: "opencode", state: action, + seq: nextReportSeq(), }, }; diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts index f51f05d1..26869f1c 100644 --- a/src/integration/assets/pi/herdr-agent-state.ts +++ b/src/integration/assets/pi/herdr-agent-state.ts @@ -1,5 +1,7 @@ // installed by herdr // safe to edit. this integration only activates inside herdr-managed panes. +// HERDR_INTEGRATION_ID=pi +// HERDR_INTEGRATION_VERSION=1 // @ts-nocheck import { createConnection } from "node:net"; @@ -37,7 +39,38 @@ function sendRequest(request: unknown): Promise { }); } -function sendState(state: "working" | "blocked" | "idle", message?: string): Promise { +type AgentState = "working" | "blocked" | "idle"; + +type QueuedState = { + state: AgentState; + message?: string; + seq: number; +}; + +const idleDebounceMs = parseDurationEnv("HERDR_PI_IDLE_DEBOUNCE_MS", 250); +const retryGraceMs = parseDurationEnv("HERDR_PI_RETRY_GRACE_MS", 2500); +const retryableErrorPattern = + /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i; +let reportSeq = Date.now() * 1000; + +function nextReportSeq(): number { + reportSeq += 1; + return reportSeq; +} + +function parseDurationEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return fallback; + } + return parsed; +} + +function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise { return sendRequest({ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, method: "pane.report_agent", @@ -47,10 +80,65 @@ function sendState(state: "working" | "blocked" | "idle", message?: string): Pro agent: "pi", state, message, + seq, }, }); } +let sendInFlight = false; +let queuedState: QueuedState | undefined; + +function queueState(state: AgentState, message?: string): void { + queuedState = { state, message, seq: nextReportSeq() }; + if (!sendInFlight) { + void drainStateQueue(); + } +} + +async function drainStateQueue(): Promise { + if (sendInFlight) { + return; + } + + sendInFlight = true; + try { + while (queuedState) { + const next = queuedState; + queuedState = undefined; + await sendState(next.state, next.message, next.seq); + } + } finally { + sendInFlight = false; + if (queuedState) { + void drainStateQueue(); + } + } +} + +function lastAssistantMessage(messages: unknown[]): any | undefined { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] as any; + if (message?.role === "assistant") { + return message; + } + } + return undefined; +} + +function retryableErrorMessage(event: any): string | undefined { + const messages = Array.isArray(event?.messages) ? event.messages : []; + const assistant = lastAssistantMessage(messages); + if (assistant?.stopReason !== "error") { + return undefined; + } + + const errorMessage = String(assistant.errorMessage ?? ""); + if (!retryableErrorPattern.test(errorMessage)) { + return undefined; + } + return errorMessage || "retryable provider error"; +} + function releaseAgent(): Promise { return sendRequest({ id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`, @@ -59,6 +147,7 @@ function releaseAgent(): Promise { pane_id: paneId, source, agent: "pi", + seq: nextReportSeq(), }, }); } @@ -69,16 +158,43 @@ export default function (pi) { } let agentActive = false; + let retryHoldActive = false; + let failureBlocked = false; + let failureMessage: string | undefined; let blockedCount = 0; let blockedMessage: string | undefined; - let lastState: "working" | "blocked" | "idle" | undefined; + let lastState: AgentState | undefined; let lastMessage: string | undefined; + let idleTimer: ReturnType | undefined; + let retryTimer: ReturnType | undefined; + + function clearTimer(timer: ReturnType | undefined) { + if (timer) { + clearTimeout(timer); + } + } + + function clearPendingTimers() { + clearTimer(idleTimer); + clearTimer(retryTimer); + idleTimer = undefined; + retryTimer = undefined; + } + + function clearFailureState() { + retryHoldActive = false; + failureBlocked = false; + failureMessage = undefined; + } function desiredState() { if (blockedCount > 0) { return { state: "blocked" as const, message: blockedMessage }; } - if (agentActive) { + if (failureBlocked) { + return { state: "blocked" as const, message: failureMessage }; + } + if (agentActive || retryHoldActive) { return { state: "working" as const, message: undefined }; } return { state: "idle" as const, message: undefined }; @@ -91,7 +207,33 @@ export default function (pi) { } lastState = next.state; lastMessage = next.message; - void sendState(next.state, next.message); + queueState(next.state, next.message); + } + + function scheduleIdle() { + clearPendingTimers(); + clearFailureState(); + idleTimer = setTimeout(() => { + idleTimer = undefined; + publishState(); + }, idleDebounceMs); + idleTimer.unref?.(); + } + + function holdForRetry(message: string) { + clearPendingTimers(); + retryHoldActive = true; + failureBlocked = false; + failureMessage = message; + publishState(); + + retryTimer = setTimeout(() => { + retryTimer = undefined; + retryHoldActive = false; + failureBlocked = true; + publishState(); + }, retryGraceMs); + retryTimer.unref?.(); } pi.events.on("herdr:blocked", (data) => { @@ -104,22 +246,40 @@ export default function (pi) { return; } + clearPendingTimers(); blockedCount += 1; blockedMessage = data.label; publishState(); }); pi.on("agent_start", () => { + clearPendingTimers(); + clearFailureState(); agentActive = true; publishState(); }); - pi.on("agent_end", () => { + pi.on("agent_end", (event) => { + if (!agentActive) { + // Pi can emit duplicate/late end events while auto-retry is already + // holding the pane in Working. Do not let an unqualified duplicate end + // cancel the retry hold and publish a false Idle. + return; + } + agentActive = false; - publishState(); + + const retryableMessage = retryableErrorMessage(event); + if (retryableMessage) { + holdForRetry(retryableMessage); + return; + } + + scheduleIdle(); }); pi.on("session_shutdown", async () => { + clearPendingTimers(); await releaseAgent(); }); } diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 402e29a7..1e5c2d68 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -1,6 +1,8 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::{Mutex, MutexGuard, OnceLock}; use portable_pty::CommandBuilder; use serde_json::{json, Map, Value}; @@ -10,12 +12,17 @@ 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"); +const PI_INTEGRATION_VERSION: u32 = 1; const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh"); +const CLAUDE_INTEGRATION_VERSION: u32 = 1; const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh"); +const CODEX_INTEGRATION_VERSION: u32 = 1; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); +const OPENCODE_INTEGRATION_VERSION: u32 = 1; +const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; #[derive(Debug)] pub(crate) struct ClaudeInstallPaths { @@ -35,6 +42,22 @@ pub(crate) struct OpenCodeInstallPaths { pub plugin_path: PathBuf, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IntegrationStatus { + pub target: crate::api::schema::IntegrationTarget, + pub path: PathBuf, + pub state: IntegrationStatusKind, + pub installed_version: Option, + pub expected_version: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IntegrationStatusKind { + NotInstalled, + Current, + Outdated, +} + #[derive(Debug)] pub(crate) struct PiUninstallResult { pub extension_path: PathBuf, @@ -213,7 +236,9 @@ pub(crate) fn uninstall_target( Ok(messages) } -fn integration_target_label(target: crate::api::schema::IntegrationTarget) -> &'static str { +pub(crate) fn integration_target_label( + target: crate::api::schema::IntegrationTarget, +) -> &'static str { match target { crate::api::schema::IntegrationTarget::Pi => "pi", crate::api::schema::IntegrationTarget::Claude => "claude", @@ -222,6 +247,136 @@ fn integration_target_label(target: crate::api::schema::IntegrationTarget) -> &' } } +pub(crate) fn installed_integration_statuses() -> Vec { + integration_specs() + .into_iter() + .filter_map(|(target, path, expected_version)| { + Some(integration_status_at(target, path.ok()?, expected_version)) + }) + .collect() +} + +fn outdated_installed_integrations() -> Vec { + installed_integration_statuses() + .into_iter() + .filter(|status| status.state == IntegrationStatusKind::Outdated) + .collect() +} + +fn integration_specs() -> [( + crate::api::schema::IntegrationTarget, + io::Result, + u32, +); 4] { + [ + ( + crate::api::schema::IntegrationTarget::Pi, + pi_extension_dir().map(|dir| dir.join(PI_EXTENSION_INSTALL_NAME)), + PI_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Claude, + claude_dir().map(|dir| dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)), + CLAUDE_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Codex, + codex_dir().map(|dir| dir.join(CODEX_HOOK_INSTALL_NAME)), + CODEX_INTEGRATION_VERSION, + ), + ( + crate::api::schema::IntegrationTarget::Opencode, + opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)), + OPENCODE_INTEGRATION_VERSION, + ), + ] +} + +pub(crate) fn integration_update_instructions( + targets: &[crate::api::schema::IntegrationTarget], +) -> String { + let commands: Vec = targets + .iter() + .map(|target| { + format!( + "`herdr integration install {}`", + integration_target_label(*target) + ) + }) + .collect(); + + match commands.as_slice() { + [] => String::new(), + [command] => format!("run {command}"), + [rest @ .., last] => format!("run {} and {last}", rest.join(", ")), + } +} + +pub(crate) fn print_outdated_update_notice() -> bool { + let outdated = outdated_installed_integrations(); + if outdated.is_empty() { + return false; + } + + let targets = outdated + .iter() + .map(|integration| integration.target) + .collect::>(); + eprintln!( + "installed herdr integrations need updating; {}.", + integration_update_instructions(&targets).replace('`', "") + ); + true +} + +fn integration_status_at( + target: crate::api::schema::IntegrationTarget, + path: PathBuf, + expected_version: u32, +) -> IntegrationStatus { + if !path.is_file() { + return IntegrationStatus { + target, + path, + state: IntegrationStatusKind::NotInstalled, + installed_version: None, + expected_version, + }; + } + + let installed_version = fs::read_to_string(&path) + .ok() + .and_then(|content| parse_integration_version(&content)); + let state = if installed_version.is_some_and(|version| version >= expected_version) { + IntegrationStatusKind::Current + } else { + IntegrationStatusKind::Outdated + }; + + IntegrationStatus { + target, + path, + state, + installed_version, + expected_version, + } +} + +fn parse_integration_version(content: &str) -> Option { + content.lines().find_map(|line| { + let marker_line = line + .trim() + .trim_start_matches('/') + .trim_start_matches('#') + .trim(); + marker_line + .strip_prefix(INTEGRATION_VERSION_MARKER)? + .trim() + .parse() + .ok() + }) +} + pub(crate) fn install_pi() -> io::Result { let dir = pi_extension_dir()?; if !dir.is_dir() { @@ -815,10 +970,15 @@ fn home_dir() -> io::Result { .map_err(|_| io::Error::other("HOME is not set; cannot locate home directory")) } +#[cfg(test)] +pub(crate) fn integration_env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() +} + #[cfg(test)] mod tests { use super::*; - use std::sync::{Mutex, MutexGuard, OnceLock}; fn unique_base() -> PathBuf { std::env::temp_dir().join(format!( @@ -831,14 +991,9 @@ mod tests { )) } - fn env_lock() -> MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())).lock().unwrap() - } - #[test] fn install_pi_writes_embedded_asset_to_pi_extensions_dir() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let ext_dir = home.join(".pi/agent/extensions"); @@ -857,7 +1012,7 @@ mod tests { #[test] fn uninstall_pi_removes_embedded_extension_when_present() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let ext_dir = home.join(".pi/agent/extensions"); @@ -878,9 +1033,51 @@ mod tests { let _ = fs::remove_dir_all(base); } + #[test] + fn outdated_integrations_treat_missing_version_marker_as_legacy() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".pi/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + let extension_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME); + fs::write(&extension_path, "// installed by herdr\n").unwrap(); + std::env::set_var("HOME", &home); + + let outdated = outdated_installed_integrations(); + + assert_eq!(outdated.len(), 1); + assert_eq!( + outdated[0].target, + crate::api::schema::IntegrationTarget::Pi + ); + assert_eq!(outdated[0].path, extension_path); + assert_eq!(outdated[0].installed_version, None); + assert_eq!(outdated[0].expected_version, PI_INTEGRATION_VERSION); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn outdated_integrations_accept_current_version_marker() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let ext_dir = home.join(".pi/agent/extensions"); + fs::create_dir_all(&ext_dir).unwrap(); + fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap(); + std::env::set_var("HOME", &home); + + assert!(outdated_installed_integrations().is_empty()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + #[test] fn install_pi_errors_when_extension_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); @@ -896,7 +1093,7 @@ mod tests { #[test] fn install_claude_writes_hook_and_updates_settings() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let claude_dir = home.join(".claude"); @@ -965,7 +1162,7 @@ mod tests { #[test] fn install_claude_is_idempotent_for_hook_entries() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let claude_dir = home.join(".claude"); @@ -1017,7 +1214,7 @@ mod tests { #[test] fn uninstall_claude_removes_herdr_hooks_and_preserves_others() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let claude_dir = home.join(".claude"); @@ -1073,7 +1270,7 @@ mod tests { #[test] fn install_claude_errors_when_claude_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); @@ -1089,7 +1286,7 @@ mod tests { #[test] fn install_codex_writes_hook_and_updates_hooks_and_config() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let codex_dir = home.join(".codex"); @@ -1133,7 +1330,7 @@ mod tests { #[test] fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let codex_dir = home.join(".codex"); @@ -1169,7 +1366,7 @@ mod tests { #[test] fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let codex_dir = home.join(".codex"); @@ -1224,7 +1421,7 @@ mod tests { #[test] fn install_codex_errors_when_config_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); @@ -1240,7 +1437,7 @@ mod tests { #[test] fn install_opencode_writes_plugin_to_plugins_dir() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let opencode_dir = home.join(".config/opencode"); @@ -1264,7 +1461,7 @@ mod tests { #[test] fn uninstall_opencode_removes_plugin_when_present() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); let opencode_dir = home.join(".config/opencode/plugins"); @@ -1287,7 +1484,7 @@ mod tests { #[test] fn install_opencode_errors_when_config_dir_missing() { - let _lock = env_lock(); + let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); fs::create_dir_all(&home).unwrap(); diff --git a/src/pane/state.rs b/src/pane/state.rs index 590f2025..8b36826b 100644 --- a/src/pane/state.rs +++ b/src/pane/state.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::detect::{Agent, AgentState}; const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200); @@ -26,6 +28,7 @@ pub struct PaneState { pub detected_agent: Option, pub fallback_state: AgentState, pub hook_authority: Option, + hook_report_sequences: HashMap, 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). @@ -38,6 +41,7 @@ impl PaneState { detected_agent: None, fallback_state: AgentState::Unknown, hook_authority: None, + hook_report_sequences: HashMap::new(), state: AgentState::Unknown, seen: true, } @@ -71,7 +75,12 @@ impl PaneState { agent_label: String, state: AgentState, message: Option, + seq: Option, ) -> Option { + if !self.accept_hook_report(&source, seq) { + return None; + } + 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; @@ -84,7 +93,39 @@ impl PaneState { self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } - pub fn clear_hook_authority(&mut self, source: Option<&str>) -> Option { + fn accept_hook_report(&mut self, source: &str, seq: Option) -> bool { + let Some(seq) = seq else { + return !self.hook_report_sequences.contains_key(source); + }; + + if self + .hook_report_sequences + .get(source) + .is_some_and(|last_seq| seq <= *last_seq) + { + return false; + } + + self.hook_report_sequences.insert(source.to_string(), seq); + true + } + + pub fn clear_hook_authority( + &mut self, + source: Option<&str>, + seq: Option, + ) -> Option { + let sequence_source = source.map(str::to_string).or_else(|| { + self.hook_authority + .as_ref() + .map(|authority| authority.source.clone()) + }); + if let Some(source) = sequence_source.as_deref() { + if !self.accept_hook_report(source, seq) { + return None; + } + } + 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; @@ -103,7 +144,12 @@ impl PaneState { &mut self, source: &str, agent_label: &str, + seq: Option, ) -> Option { + if !self.accept_hook_report(source, seq) { + return None; + } + let current_agent_label = self.effective_agent_label()?; if current_agent_label != agent_label { return None; @@ -260,7 +306,13 @@ 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(), "pi".into(), AgentState::Working, None); + pane.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + ); assert_eq!(pane.detected_agent, Some(Agent::Pi)); assert_eq!(pane.fallback_state, AgentState::Idle); @@ -277,6 +329,7 @@ mod tests { "hermes".into(), AgentState::Working, None, + None, ); assert_eq!(pane.detected_agent, Some(Agent::Pi)); @@ -294,6 +347,7 @@ mod tests { "hermes".into(), AgentState::Working, None, + None, ); pane.set_detected_state(None, AgentState::Unknown); @@ -313,6 +367,7 @@ mod tests { "opencode".into(), AgentState::Idle, None, + None, ); pane.set_detected_state(None, AgentState::Unknown); @@ -328,7 +383,13 @@ mod tests { fn detected_agent_change_clears_previous_matching_hook_authority() { let mut pane = PaneState::new(); pane.set_detected_state(Some(Agent::Codex), AgentState::Idle); - pane.set_hook_authority("herdr:codex".into(), "codex".into(), AgentState::Idle, None); + pane.set_hook_authority( + "herdr:codex".into(), + "codex".into(), + AgentState::Idle, + None, + None, + ); pane.set_detected_state(Some(Agent::OpenCode), AgentState::Working); @@ -342,13 +403,123 @@ mod tests { 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(), "pi".into(), AgentState::Working, None); + pane.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + None, + ); - pane.release_agent("herdr:pi", "pi"); + pane.release_agent("herdr:pi", "pi", None); 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 stale_hook_report_sequence_is_ignored_for_same_source() { + let mut pane = PaneState::new(); + pane.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + let change = pane.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Idle, + None, + Some(19), + ); + + assert!(change.is_none()); + assert_eq!(pane.state, AgentState::Working); + assert_eq!( + pane.hook_authority.as_ref().unwrap().state, + AgentState::Working + ); + } + + #[test] + fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() { + let mut pane = PaneState::new(); + pane.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + let change = + pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Idle, None, None); + + assert!(change.is_none()); + assert_eq!(pane.state, AgentState::Working); + } + + #[test] + fn stale_release_sequence_is_ignored_for_same_source() { + let mut pane = PaneState::new(); + pane.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + let change = pane.release_agent("herdr:pi", "pi", Some(19)); + + assert!(change.is_none()); + assert_eq!(pane.state, AgentState::Working); + assert!(pane.hook_authority.is_some()); + } + + #[test] + fn stale_clear_all_sequence_is_checked_against_current_authority_source() { + let mut pane = PaneState::new(); + pane.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + let change = pane.clear_hook_authority(None, Some(19)); + + assert!(change.is_none()); + assert_eq!(pane.state, AgentState::Working); + assert!(pane.hook_authority.is_some()); + } + + #[test] + fn same_sequence_from_different_sources_is_independent() { + let mut pane = PaneState::new(); + pane.set_hook_authority( + "herdr:pi".into(), + "pi".into(), + AgentState::Working, + None, + Some(20), + ); + + pane.set_hook_authority( + "custom:pi".into(), + "pi".into(), + AgentState::Idle, + None, + Some(19), + ); + + assert_eq!(pane.state, AgentState::Idle); + assert_eq!(pane.hook_authority.as_ref().unwrap().source, "custom:pi"); + } } diff --git a/src/server/headless.rs b/src/server/headless.rs index 7bd8638a..bd1bf55e 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -689,6 +689,19 @@ impl HeadlessServer { changed } + fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState { + self.app + .state + .workspaces + .iter() + .find_map(|ws| { + ws.tabs + .iter() + .find_map(|tab| tab.panes.get(&pane_id).map(|pane| pane.state)) + }) + .unwrap_or(crate::detect::AgentState::Unknown) + } + /// Handles a single internal event with forwarding logic for clipboard, /// sound, and toast notifications to connected clients. /// @@ -710,31 +723,16 @@ impl HeadlessServer { // ClipboardWrite doesn't change visual state — no render needed. false } - AppEvent::StateChanged { - pane_id, - agent, - state, - } => { + AppEvent::StateChanged { pane_id, agent, .. } => { // Capture toast before handling. let toast_before = self.app.state.toast.clone(); let pane_id_val = *pane_id; let agent_val = *agent; - let state_val = *state; - // Find the previous state of this pane before the event - // is processed. We need this to determine if a sound - // notification would be triggered. - let prev_state = self - .app - .state - .workspaces - .iter() - .find_map(|ws| { - ws.tabs - .iter() - .find_map(|tab| tab.panes.get(&pane_id_val).map(|p| p.state)) - }) - .unwrap_or(crate::detect::AgentState::Unknown); + // Find the previous effective state of this pane before the event + // is processed. Notifications must follow effective state changes, + // not raw fallback reports that may be masked by hook authority. + let prev_state = self.pane_effective_state(pane_id_val); // Handle the state change (updates pane state, sets toast on AppState). // Headless mode disables local sound playback separately from the @@ -756,11 +754,13 @@ impl HeadlessServer { let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); + let next_state = self.pane_effective_state(pane_id_val); + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, prev_state, - state_val, + next_state, ) { let msg = match sound { crate::sound::Sound::Done => "agent done", @@ -787,7 +787,7 @@ impl HeadlessServer { pane_id_val, suppress_active_tab_notifications, prev_state, - state_val, + next_state, ) } } else { @@ -806,43 +806,24 @@ impl HeadlessServer { AppEvent::HookStateReported { pane_id, agent_label, - state, .. } => { - // The hook authority may not change the effective pane state if - // the detected agent doesn't match. We forward based on the - // hook-reported state transition regardless. + // Hook reports can be stale or no-op after sequence rejection. + // Forward only effective state changes observed after handling. let toast_before = self.app.state.toast.clone(); let pane_id_val = *pane_id; let agent_val = crate::detect::parse_agent_label(agent_label); - let hook_state_val = *state; - // Capture the previous hook authority state for this pane. - // If no hook authority exists, use the effective state. - let prev_hook_state = self - .app - .state - .workspaces - .iter() - .find_map(|ws| { - ws.tabs.iter().find_map(|tab| { - tab.panes.get(&pane_id_val).map(|p| { - p.hook_authority - .as_ref() - .map(|h| h.state) - .unwrap_or(p.state) - }) - }) - }) - .unwrap_or(crate::detect::AgentState::Unknown); + // Capture the previous effective state for this pane. Hook reports + // are already folded into pane.state; raw hook transitions must not + // produce a second notification path. + let prev_state = self.pane_effective_state(pane_id_val); self.sync_foreground_client_state(); self.app.handle_internal_event(ev); - // Forward sound notification based on hook state transition when - // server-side sound policy allows it. This ensures API-reported state - // changes (pane.report_agent) produce notifications even before - // fallback detection confirms. + // Forward sound notification based on the effective transition when + // server-side sound policy allows it. let is_active_tab = self .app .state @@ -856,11 +837,13 @@ impl HeadlessServer { let suppress_active_tab_notifications = self.active_tab_suppresses_notifications(is_active_tab); + let next_state = self.pane_effective_state(pane_id_val); + if self.app.state.sound.allows(agent_val) { if let Some(sound) = crate::app::actions::notification_sound_for_state_change( suppress_active_tab_notifications, - prev_hook_state, - hook_state_val, + prev_state, + next_state, ) { let msg = match sound { crate::sound::Sound::Done => "agent done", @@ -886,8 +869,8 @@ impl HeadlessServer { &self.app.state, pane_id_val, suppress_active_tab_notifications, - prev_hook_state, - hook_state_val, + prev_state, + next_state, ) } } else { @@ -1228,42 +1211,26 @@ impl HeadlessServer { let changed = api::request_changes_ui(&msg.request); - // Capture toast and pane agent states before the API call so we can - // forward any resulting notifications to connected clients. - // API requests like pane.report_agent trigger handle_internal_event - // internally, which bypasses drain_internal_events_with_forwarding. - // Headless mode disables local sound playback, so sound notifications - // need to be forwarded to clients here; toasts may be set but not forwarded. - // - // Note: pane.report_agent sets hook_authority on the pane, but the - // effective state may not change until the fallback detector confirms - // the agent (detected_agent must match). So we capture both the - // effective state AND the hook authority state for comparison. + // Capture toast and effective pane states before the API call so we can + // forward resulting client-local notifications. API requests like + // pane.report_agent trigger handle_internal_event internally, which + // bypasses drain_internal_events_with_forwarding. Headless mode disables + // local sound playback, so sound notifications need to be forwarded here. let toast_before = self.app.state.toast.clone(); - let pane_states_before: Vec<( - usize, - crate::layout::PaneId, - crate::detect::AgentState, - Option, - )> = self - .app - .state - .workspaces - .iter() - .enumerate() - .flat_map(|(ws_idx, ws)| { - ws.tabs.iter().flat_map(move |tab| { - tab.panes.iter().map(move |(&pane_id, pane)| { - ( - ws_idx, - pane_id, - pane.state, - pane.hook_authority.as_ref().map(|h| h.state), - ) + let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> = + self.app + .state + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, ws)| { + ws.tabs.iter().flat_map(move |tab| { + tab.panes + .iter() + .map(move |(&pane_id, pane)| (ws_idx, pane_id, pane.state)) }) }) - }) - .collect(); + .collect(); self.sync_foreground_client_state(); let response = self.app.handle_api_request(msg.request); @@ -1293,16 +1260,10 @@ impl HeadlessServer { false }; - // Forward sound notifications for any pane state changes that occurred - // during the API request. Compare before/after pane states (including - // hook authority state) to find transitions that would trigger a sound. - // - // pane.report_agent sets hook_authority on the pane, but the effective - // state may not change until the fallback detector confirms the agent - // (detected_agent must match hook_authority.agent). We check BOTH the - // effective state AND the hook authority state so that API-reported - // state changes trigger notifications even before fallback confirmation. - for (ws_idx, pane_id, prev_effective_state, prev_hook_state) in &pane_states_before { + // Forward notifications for effective pane state changes that occurred + // during the API request. Hook authority is already folded into + // pane.state, so raw hook transitions must not produce separate sounds. + for (ws_idx, pane_id, prev_state) in &pane_states_before { let pane_after = self .app .state @@ -1314,97 +1275,77 @@ impl HeadlessServer { continue; }; - let new_effective_state = pane_after.state; - let new_hook_state = pane_after.hook_authority.as_ref().map(|h| h.state); + let new_state = pane_after.state; + if new_state == *prev_state { + continue; + } - // Check effective state change first. - let effective_changed = new_effective_state != *prev_effective_state; - // Check hook authority state change — this catches API-reported - // state changes that haven't been confirmed by fallback detection. - let hook_changed = new_hook_state != *prev_hook_state; + let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id); + let suppress_active_tab_notifications = + self.active_tab_suppresses_notifications(is_active_tab); - if effective_changed || hook_changed { - // Use the hook state if available (it reflects the API-reported - // state), otherwise use the effective state. - let prev_state = prev_hook_state.unwrap_or(*prev_effective_state); - let new_state = new_hook_state.unwrap_or(new_effective_state); + let agent = pane_after.effective_known_agent(); - // Skip if the derived transition is a no-op. - if prev_state == new_state { - continue; - } + debug!( + ws_idx, + pane_id = pane_id.raw(), + prev_state = ?prev_state, + new_state = ?new_state, + agent = ?agent, + "pane effective state changed during API request, checking notification" + ); - let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id); - let suppress_active_tab_notifications = - self.active_tab_suppresses_notifications(is_active_tab); - - // Get the known agent for sound settings. Unknown custom labels - // fall back to None so clients use the generic sound behavior. - let agent = pane_after.effective_known_agent(); - - debug!( - ws_idx, - pane_id = pane_id.raw(), - prev_state = ?prev_state, - new_state = ?new_state, - agent = ?agent, - effective_changed, - hook_changed, - "pane state changed during API request, checking sound notification" - ); - - if !forwarded_toast_from_state - && should_forward_toast_to_clients(self.app.state.toast_config.delivery) - { - if let Some(kind) = crate::app::actions::notification_toast_for_state_change( - suppress_active_tab_notifications, - prev_state, - new_state, - ) { - if let Some(agent_label) = pane_after.effective_agent_label() { - let event_text = match kind { - crate::app::state::ToastKind::NeedsAttention => "needs attention", - crate::app::state::ToastKind::Finished => "finished", - crate::app::state::ToastKind::UpdateInstalled => "updated", - }; - let msg_text = format!( - "{} {}: {}", - agent_label, - event_text, - crate::app::actions::notification_context( - &self.app.state.workspaces[*ws_idx], - *ws_idx, - *pane_id, - ) - ); - self.send_to_foreground_client(ServerMessage::Notify { - kind: protocol::NotifyKind::Toast, - message: msg_text, - }); - } - } - } - - // Forward sound notification when server-side sound policy allows it. - // Clients still decide locally whether they can execute the side effect. - if self.app.state.sound.allows(agent) { - if let Some(sound) = crate::app::actions::notification_sound_for_state_change( - suppress_active_tab_notifications, - prev_state, - new_state, - ) { - let msg_text = match sound { - crate::sound::Sound::Done => "agent done", - crate::sound::Sound::Request => "agent attention", + if !forwarded_toast_from_state + && should_forward_toast_to_clients(self.app.state.toast_config.delivery) + { + if let Some(kind) = crate::app::actions::notification_toast_for_state_change( + suppress_active_tab_notifications, + *prev_state, + new_state, + ) { + if let Some(agent_label) = pane_after.effective_agent_label() { + let event_text = match kind { + crate::app::state::ToastKind::NeedsAttention => "needs attention", + crate::app::state::ToastKind::Finished => "finished", + crate::app::state::ToastKind::UpdateInstalled => "updated", }; - debug!(sound = ?sound, "forwarding sound notification from API request"); + let msg_text = format!( + "{} {}: {}", + agent_label, + event_text, + crate::app::actions::notification_context( + &self.app.state.workspaces[*ws_idx], + *ws_idx, + *pane_id, + ) + ); self.send_to_foreground_client(ServerMessage::Notify { - kind: protocol::NotifyKind::Sound, - message: msg_text.to_owned(), + kind: protocol::NotifyKind::Toast, + message: msg_text, }); } } } + + // Forward sound notification when server-side sound policy allows it. + // Clients still decide locally whether they can execute the side effect. + if self.app.state.sound.allows(agent) { + if let Some(sound) = crate::app::actions::notification_sound_for_state_change( + suppress_active_tab_notifications, + *prev_state, + new_state, + ) { + let msg_text = match sound { + crate::sound::Sound::Done => "agent done", + crate::sound::Sound::Request => "agent attention", + }; + debug!(sound = ?sound, "forwarding sound notification from API request"); + self.send_to_foreground_client(ServerMessage::Notify { + kind: protocol::NotifyKind::Sound, + message: msg_text.to_owned(), + }); + } + } } changed @@ -2646,6 +2587,77 @@ mod tests { ); } + #[test] + fn stale_api_agent_report_does_not_forward_done_sound() { + let mut server = test_headless_server(); + let mut background = crate::workspace::Workspace::test_new("background"); + let pane_id = background.tabs[0].root_pane; + let public_pane_id = format!("{}-1", background.id); + background.tabs[0] + .panes + .get_mut(&pane_id) + .unwrap() + .set_hook_authority( + "herdr:pi".into(), + "pi".into(), + crate::detect::AgentState::Working, + None, + Some(20), + ); + let foreground = crate::workspace::Workspace::test_new("foreground"); + server.app.state.workspaces = vec![background, foreground]; + server.app.state.active = Some(1); + server.app.state.selected = 1; + server.app.state.mode = crate::app::Mode::Terminal; + + let (client_tx, client_control_rx, _client_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(1); + server.sync_foreground_client_state(); + + let (respond_to, response_rx) = std::sync::mpsc::channel(); + let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "stale".into(), + method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams { + pane_id: public_pane_id, + source: "herdr:pi".into(), + agent: "pi".into(), + state: api::schema::PaneAgentState::Idle, + message: None, + seq: Some(19), + }), + }, + respond_to, + }); + + assert!(changed); + assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok()); + assert_eq!( + server.app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .state, + crate::detect::AgentState::Working + ); + assert!( + client_control_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "stale idle report must not forward a done sound" + ); + } + /// Verify that no direct calls to `self.app.handle_internal_event` /// exist outside of `handle_internal_event_with_forwarding` in this /// module. This ensures the forwarding bypass cannot be reintroduced. diff --git a/src/update.rs b/src/update.rs index c4c5b162..68528795 100644 --- a/src/update.rs +++ b/src/update.rs @@ -563,9 +563,11 @@ pub fn self_update() -> Result { tracing::warn!("failed to save pending release notes: {e}"); } let downloaded_update = download_update(&release)?; + let updated_exe = downloaded_update.current_exe.clone(); let stopped_server = stop_running_server_for_update(running_server_plan.as_ref(), &release)?; install_downloaded_update(downloaded_update)?; eprintln!("updated to v{}", release.version); + print_outdated_integration_notice_with_updated_binary(&updated_exe); if stopped_server { eprintln!("run herdr again to start the updated server."); @@ -578,6 +580,16 @@ pub fn self_update() -> Result { Ok(release.version) } +fn print_outdated_integration_notice_with_updated_binary(updated_exe: &Path) { + let status = Command::new(updated_exe) + .args(["integration", "status", "--outdated-only"]) + .status(); + + if !status.is_ok_and(|status| status.success()) { + crate::integration::print_outdated_update_notice(); + } +} + /// Background update check: only surface availability and release notes. /// Runs in a background thread at startup. pub fn auto_update(events: tokio::sync::mpsc::Sender) { diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index 53e08920..bd476667 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -183,6 +183,7 @@ mod tests { "hermes".into(), AgentState::Working, None, + None, ); let details = ws.pane_details(); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index de0d141f..473a6934 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -454,6 +454,7 @@ fn claude_hook_reports_subagent_working_and_blocked() { run_claude_hook("working", subagent_input).expect("subagent working should report working"); assert_eq!(working["method"], "pane.report_agent"); assert_eq!(working["params"]["state"], "working"); + assert!(working["params"]["seq"].as_u64().is_some()); let blocked = run_claude_hook("blocked", subagent_input).expect("subagent blocked should report blocked"); @@ -833,6 +834,17 @@ fn integration_commands_run_locally_when_server_is_missing() { "integration install should write local files without a server" ); + let integration_status = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["integration", "status"]) + .env("HERDR_SOCKET_PATH", &missing_socket) + .env("HOME", &home_dir) + .output() + .unwrap(); + assert_eq!(integration_status.status.code(), Some(0)); + let status_stdout = String::from_utf8_lossy(&integration_status.stdout); + assert!(status_stdout.contains("pi: current (v1)")); + assert!(status_stdout.contains("claude: not installed")); + let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr")) .args(["integration", "uninstall", "pi"]) .env("HERDR_SOCKET_PATH", &missing_socket) @@ -848,6 +860,61 @@ fn integration_commands_run_locally_when_server_is_missing() { cleanup_test_base(&base); } +#[test] +fn integration_status_outdated_only_prints_action_for_legacy_install() { + let base = unique_test_dir(); + let home_dir = base.join("home"); + let extensions_dir = home_dir.join(".pi/agent/extensions"); + fs::create_dir_all(&extensions_dir).unwrap(); + fs::write( + extensions_dir.join("herdr-agent-state.ts"), + "// legacy herdr integration\n", + ) + .unwrap(); + + let runtime_dir = base.join("runtime"); + fs::create_dir_all(&runtime_dir).unwrap(); + register_runtime_dir(&runtime_dir); + let missing_socket = runtime_dir.join("missing.sock"); + + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["integration", "status", "--outdated-only"]) + .env("HERDR_SOCKET_PATH", &missing_socket) + .env("HOME", &home_dir) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(0)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("installed herdr integrations need updating")); + assert!(stderr.contains("herdr integration install pi")); + + cleanup_test_base(&base); +} + +#[test] +fn integration_status_rejects_unknown_flags() { + let base = unique_test_dir(); + let home_dir = base.join("home"); + fs::create_dir_all(&home_dir).unwrap(); + let runtime_dir = base.join("runtime"); + fs::create_dir_all(&runtime_dir).unwrap(); + register_runtime_dir(&runtime_dir); + let missing_socket = runtime_dir.join("missing.sock"); + + let output = Command::new(env!("CARGO_BIN_EXE_herdr")) + .args(["integration", "status", "--wat"]) + .env("HERDR_SOCKET_PATH", &missing_socket) + .env("HOME", &home_dir) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + + cleanup_test_base(&base); +} + #[test] fn status_commands_report_client_and_server_versions() { let base = unique_test_dir();