aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-25 01:04:58 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-25 01:05:14 +0300
commit5f1957c27bd23f73b339265d3acd32f4a21878a3 (patch)
tree3a89bcc452130df0b18becb7aa9ec4c6008262ba /src
parent38d2b0780c50e4773d6948660873386261cde71d (diff)
fix: harden hermes lifecycle detection
Diffstat (limited to 'src')
-rw-r--r--src/detect/manifests/hermes.toml94
-rw-r--r--src/detect/mod.rs12
-rw-r--r--src/integration/assets/hermes/__init__.py63
-rw-r--r--src/integration/tests.rs8
-rw-r--r--src/terminal/state.rs152
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]