diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-25 01:04:58 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-25 01:05:14 +0300 |
| commit | 5f1957c27bd23f73b339265d3acd32f4a21878a3 (patch) | |
| tree | 3a89bcc452130df0b18becb7aa9ec4c6008262ba /src | |
| parent | 38d2b0780c50e4773d6948660873386261cde71d (diff) | |
fix: harden hermes lifecycle detection
Diffstat (limited to 'src')
| -rw-r--r-- | src/detect/manifests/hermes.toml | 94 | ||||
| -rw-r--r-- | src/detect/mod.rs | 12 | ||||
| -rw-r--r-- | src/integration/assets/hermes/__init__.py | 63 | ||||
| -rw-r--r-- | src/integration/tests.rs | 8 | ||||
| -rw-r--r-- | src/terminal/state.rs | 152 |
5 files changed, 251 insertions, 78 deletions
diff --git a/src/detect/manifests/hermes.toml b/src/detect/manifests/hermes.toml index d45fe32a..17542184 100644 --- a/src/detect/manifests/hermes.toml +++ b/src/detect/manifests/hermes.toml @@ -1,30 +1,102 @@ id = "hermes" -version = "2026.06.10.1" -min_engine_version = 1 -updated_at = "2026-06-10T00:00:00Z" +version = "2026.07.24.1" +min_engine_version = 2 +updated_at = "2026-07-24T19:12:54Z" aliases = ["hermes-agent"] [[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1100 +region = "osc_title" +visible_blocker = true +regex = ['^⚠[\u{fe0e}\u{fe0f}]?(?:\s|$)'] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 1050 +region = "osc_title" +visible_working = true +regex = ['^⏳[\u{fe0e}\u{fe0f}]?(?:\s|$)'] + +[[rules]] id = "dangerous_command_approval" state = "blocked" -priority = 300 -region = "whole_recent" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["dangerous"] }, + { contains = ["approval"] }, + { contains = ["allow once", "deny"] }, + { line_regex = ['(?i)^\s*[▸>]?\s*1\.\s*allow'] }, +] +all = [ + { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] }, +] + +[[rules]] +id = "clarification_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true +any = [ + { contains = ["hermes needs your"] }, + { line_regex = ['^\s*ask\s+\S'] }, + { contains = ["type your answer"] }, +] +all = [ + { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["enter send"] }, { contains = ["press enter"] }, { contains = ["↑/↓ select"] }, { contains = ["↑/↓ to select"] }, { contains = ["other (type"] }] }, +] + +[[rules]] +id = "credential_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" visible_blocker = true any = [ - { contains = ["dangerous command"] }, - { contains = ["allow once", "allow for this session", "deny"] }, + { contains = ["sudo password"] }, + { contains = ["skill setup"] }, + { contains = ["🔑", "for "] }, ] + +[[rules]] +id = "confirmation_prompt" +state = "blocked" +priority = 900 +region = "bottom_non_empty_lines(14)" +visible_blocker = true all = [ - { any = [{ contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] }, + { any = [{ contains = ["approve once", "cancel"] }, { contains = ["start a new session", "keep going"] }] }, + { any = [{ contains = ["enter to confirm"] }, { contains = ["enter confirm"] }, { contains = ["type 1/2/3"] }, { contains = ["y/n quick"] }] }, ] [[rules]] id = "interrupt_status_working" state = "working" -priority = 100 -region = "whole_recent" +priority = 950 +region = "bottom_non_empty_lines(5)" visible_working = true any = [ { contains = ["msg=interrupt"] }, - { contains = ["ctrl+c cancel"] }, + { contains = ["ctrl+c to interrupt"] }, ] + +[[rules]] +id = "classic_cancel_working" +state = "working" +priority = 500 +region = "bottom_non_empty_lines(5)" +visible_working = true +contains = ["ctrl+c cancel"] + +[[rules]] +id = "osc_title_idle" +state = "idle" +priority = 100 +region = "osc_title" +visible_idle = true +regex = ['^✓[\u{fe0e}\u{fe0f}]?(?:\s|$)'] diff --git a/src/detect/mod.rs b/src/detect/mod.rs index b8eb8f60..33c4361c 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -286,13 +286,16 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> ("herdr:pi", "pi") | ("herdr:omp", "omp") | ("herdr:mastracode", "mastracode") - | ("herdr:hermes", "hermes") | ("herdr:opencode", "opencode") | ("herdr:kilo", "kilo") | ("herdr:kimi", "kimi") ) } +pub(crate) fn session_identity_only_integration(source: &str, agent_label: &str) -> bool { + (source, agent_label) == ("herdr:hermes", "hermes") +} + // --------------------------------------------------------------------------- // Process identification (platform-specific) // --------------------------------------------------------------------------- @@ -768,6 +771,13 @@ mod tests { } #[test] + fn hermes_session_integration_leaves_state_to_screen_detection() { + assert!(!full_lifecycle_hook_authority("herdr:hermes", "hermes")); + assert!(session_identity_only_integration("herdr:hermes", "hermes")); + assert!(Agent::SCREEN_MANIFEST_AGENTS.contains(&Agent::Hermes)); + } + + #[test] fn identify_unknown_processes() { assert_eq!(identify_agent("bash"), None); assert_eq!(identify_agent("zsh"), None); diff --git a/src/integration/assets/hermes/__init__.py b/src/integration/assets/hermes/__init__.py index 5474e5e3..9b1d51e2 100644 --- a/src/integration/assets/hermes/__init__.py +++ b/src/integration/assets/hermes/__init__.py @@ -1,4 +1,4 @@ -"""Hermes plugin installed by Herdr to report agent lifecycle state.""" +"""Hermes plugin installed by Herdr to report resumable session identity.""" # HERDR_INTEGRATION_ID=hermes # HERDR_INTEGRATION_VERSION=4 @@ -13,6 +13,7 @@ import time _SOURCE = "herdr:hermes" _AGENT = "hermes" +_INTERACTIVE_PLATFORMS = {"cli", "tui", "desktop", "acp"} def _base_params() -> tuple[str, str] | None: @@ -56,53 +57,35 @@ def _send(method: str, params: dict) -> None: pass -def _session_id(kwargs: dict) -> str | None: - value = kwargs.get("session_id") - if isinstance(value, str) and value: - return value - return None - - -def _report(state: str, **kwargs) -> None: - params = {"state": state} - session_id = _session_id(kwargs) - if session_id: - params["agent_session_id"] = session_id - _send("pane.report_agent", params) +def _report_session(start_source: str, **kwargs) -> None: + if kwargs.get("platform") not in _INTERACTIVE_PLATFORMS: + return + session_id = kwargs.get("session_id") + if not isinstance(session_id, str) or not session_id: + return + _send( + "pane.report_agent_session", + { + "agent_session_id": session_id, + "session_start_source": start_source, + }, + ) def _session_started(**kwargs) -> None: - session_id = _session_id(kwargs) - if session_id: - _send( - "pane.report_agent_session", - { - "agent_session_id": session_id, - "session_start_source": "startup", - }, - ) - _idle(**kwargs) - - -def _working(**kwargs) -> None: - _report("working", **kwargs) + _report_session("startup", **kwargs) -def _blocked(**kwargs) -> None: - _report("blocked", **kwargs) +def _session_reset(**kwargs) -> None: + _report_session("new", **kwargs) -def _idle(**kwargs) -> None: - _report("idle", **kwargs) +def _session_observed(**kwargs) -> None: + if kwargs.get("platform") == "cli": + _report_session("resume", **kwargs) def register(ctx): ctx.register_hook("on_session_start", _session_started) - ctx.register_hook("pre_llm_call", _working) - ctx.register_hook("pre_api_request", _working) - ctx.register_hook("pre_tool_call", _working) - ctx.register_hook("post_tool_call", _working) - ctx.register_hook("pre_approval_request", _blocked) - ctx.register_hook("post_approval_response", _working) - ctx.register_hook("post_llm_call", _idle) - ctx.register_hook("on_session_end", _idle) + ctx.register_hook("on_session_reset", _session_reset) + ctx.register_hook("pre_llm_call", _session_observed) diff --git a/src/integration/tests.rs b/src/integration/tests.rs index f695bf52..6e3c756c 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -2747,14 +2747,6 @@ fn bundled_integration_assets_report_session_refs() { assert!(KILO_PLUGIN_ASSET.contains("session_start_source: \"startup\"")); assert!(KILO_PLUGIN_ASSET.contains("reportState")); assert!(!KILO_PLUGIN_ASSET.contains("pane.release_agent")); - assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)")); - assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id")); - assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent_session\",")); - assert!(HERMES_PLUGIN_INIT_ASSET.contains("\"session_start_source\": \"startup\"")); - assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\",")); - assert!(HERMES_PLUGIN_INIT_ASSET.contains("on_session_end")); - assert!(!HERMES_PLUGIN_INIT_ASSET.contains("on_session_finalize")); - assert!(!HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent")); assert!(QODERCLI_HOOK_ASSET.contains("HERDR_PANE_ID")); assert!(QODERCLI_HOOK_ASSET.contains("session_id")); assert!(QODERCLI_HOOK_ASSET.contains("report-agent-session")); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index 4485c773..e806967e 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -605,6 +605,9 @@ impl TerminalState { seq: Option<u64>, now: Instant, ) -> Option<TerminalStateMutation> { + if crate::detect::session_identity_only_integration(&source, &agent_label) { + return None; + } if !crate::detect::full_lifecycle_hook_authority(&source, &agent_label) && self.recent_agent_process_exit.is_some_and(|exit| { crate::detect::parse_agent_label(&agent_label) == Some(exit.agent) @@ -1280,6 +1283,7 @@ impl TerminalState { "codex", Some("startup" | "clear" | "resume" | "compact") ) | ("herdr:mastracode", "mastracode", Some("startup")) + | ("herdr:hermes", "hermes", Some("startup" | "new" | "resume")) | ("herdr:opencode", "opencode", Some("new")) | ("herdr:pi", "pi", Some("new" | "resume" | "fork")) | ( @@ -1414,6 +1418,21 @@ impl TerminalState { &agent_label, session_start_source.as_deref(), ); + let replacing_hermes_session = + crate::detect::session_identity_only_integration(&source, &agent_label) + && session_replacement_allowed + && self.current_session_identity_for_persistence().is_some_and( + |(current_source, current_agent, current_kind, current_value)| { + current_source == source + && current_agent == agent_label + && current_kind == crate::agent_resume::AgentSessionRefKind::Id + && session_ref.kind == crate::agent_resume::AgentSessionRefKind::Id + && current_value != session_ref.value + }, + ); + if replacing_hermes_session && !process_present { + return None; + } let owner_conflicts = self.current_session_owner_conflicts(&source, &agent_label); let foreground_takeover_allowed = owner_conflicts && self.foreground_agent_confirms_different_owner_takeover( @@ -2256,7 +2275,6 @@ mod tests { for (agent, source, label) in [ (Agent::Kimi, "herdr:kimi", "kimi"), (Agent::Kilo, "herdr:kilo", "kilo"), - (Agent::Hermes, "herdr:hermes", "hermes"), ] { let mut terminal = test_terminal(); terminal.set_detected_state(Some(agent), AgentState::Idle); @@ -2291,6 +2309,104 @@ mod tests { } #[test] + fn hermes_session_claim_leaves_state_to_detection() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + let session_ref = crate::agent_resume::AgentSessionRef::id("hermes-root").unwrap(); + + let session = terminal.set_agent_session_ref_for_session_start( + "herdr:hermes".into(), + "hermes".into(), + Some(session_ref.clone()), + Some(10), + Some("startup".into()), + ); + + assert!(session.is_some()); + assert!(terminal.hook_authority.is_none()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&session_ref) + ); + + terminal.set_detected_state(Some(Agent::Hermes), AgentState::Working); + + assert_eq!(terminal.state, AgentState::Working); + assert!(terminal.hook_authority.is_none()); + + let replacement_ref = + crate::agent_resume::AgentSessionRef::id("hermes-replacement").unwrap(); + let replacement = terminal.set_agent_session_ref_for_session_start( + "herdr:hermes".into(), + "hermes".into(), + Some(replacement_ref.clone()), + Some(11), + Some("startup".into()), + ); + + assert!(replacement.is_some()); + assert_eq!(terminal.state, AgentState::Working); + assert!(terminal.hook_authority.is_none()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&replacement_ref) + ); + + let legacy_state = terminal.set_hook_authority_with_session_ref( + "herdr:hermes".into(), + "hermes".into(), + AgentState::Blocked, + None, + Some(replacement_ref.clone()), + Some(12), + ); + assert!(legacy_state.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert!(terminal.hook_authority.is_none()); + + terminal.set_detected_state(None, AgentState::Unknown); + let background_replacement = terminal.set_agent_session_ref_for_session_start( + "herdr:hermes".into(), + "hermes".into(), + crate::agent_resume::AgentSessionRef::id("hermes-background"), + Some(13), + Some("resume".into()), + ); + assert!(background_replacement.is_none()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&replacement_ref) + ); + + terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + let retried_ref = crate::agent_resume::AgentSessionRef::id("hermes-background").unwrap(); + let retried_replacement = terminal.set_agent_session_ref_for_session_start( + "herdr:hermes".into(), + "hermes".into(), + Some(retried_ref.clone()), + Some(14), + Some("resume".into()), + ); + assert!(retried_replacement.is_some()); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&retried_ref) + ); + } + + #[test] fn pi_session_replacement_reports_reanchor_full_lifecycle_authority() { for reason in ["new", "resume", "fork"] { let mut terminal = test_terminal(); @@ -3425,17 +3541,17 @@ mod tests { fn visible_working_does_not_override_full_lifecycle_hook_idle() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Kimi), AgentState::Idle); anchor_full_lifecycle_session( &mut terminal, - Agent::Hermes, - "herdr:hermes", - "hermes", - crate::agent_resume::AgentSessionRef::id("hermes-root").unwrap(), + Agent::Kimi, + "herdr:kimi", + "kimi", + crate::agent_resume::AgentSessionRef::id("kimi-root").unwrap(), ); terminal.set_hook_authority_at( - "herdr:hermes".into(), - "hermes".into(), + "herdr:kimi".into(), + "kimi".into(), AgentState::Idle, None, None, @@ -3444,7 +3560,7 @@ mod tests { ); let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Hermes), + Some(Agent::Kimi), AgentState::Working, false, false, @@ -5167,20 +5283,20 @@ mod tests { #[test] fn detected_agent_disappearance_does_not_clear_full_lifecycle_hook_session_ref() { let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Kimi), AgentState::Idle); anchor_full_lifecycle_session( &mut terminal, - Agent::Hermes, - "herdr:hermes", - "hermes", - crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(), + Agent::Kimi, + "herdr:kimi", + "kimi", + crate::agent_resume::AgentSessionRef::id("kimi-session").unwrap(), ); terminal.set_hook_authority_with_session_ref( - "herdr:hermes".into(), - "hermes".into(), + "herdr:kimi".into(), + "kimi".into(), AgentState::Working, None, - crate::agent_resume::AgentSessionRef::id("hermes-session"), + crate::agent_resume::AgentSessionRef::id("kimi-session"), Some(20), ); @@ -5189,7 +5305,7 @@ mod tests { assert!(!mutation.session_ref_changed); assert!(terminal.hook_authority.is_some()); assert!(terminal.persisted_agent_session.is_none()); - assert_eq!(terminal.effective_agent_label(), Some("hermes")); + assert_eq!(terminal.effective_agent_label(), Some("kimi")); } #[test] |
