aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-19 00:23:20 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-19 00:26:05 +0300
commite7f94a3f359fe1be94a0863b731d94d245093848 (patch)
treef89459235e9a163bbafc68fb485d9355b6fd1e3f /src/integration
parent19295b85e5d6c2c7a9bce5d52343d89749d9f628 (diff)
feat: support hermes agent integration
refs #144
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/assets/hermes/__init__.py97
-rw-r--r--src/integration/assets/hermes/plugin.yaml3
-rw-r--r--src/integration/mod.rs375
3 files changed, 474 insertions, 1 deletions
diff --git a/src/integration/assets/hermes/__init__.py b/src/integration/assets/hermes/__init__.py
new file mode 100644
index 00000000..03d15a55
--- /dev/null
+++ b/src/integration/assets/hermes/__init__.py
@@ -0,0 +1,97 @@
+"""Hermes plugin installed by Herdr to report agent lifecycle state."""
+
+# HERDR_INTEGRATION_ID=hermes
+# HERDR_INTEGRATION_VERSION=1
+
+from __future__ import annotations
+
+import json
+import os
+import random
+import socket
+import time
+
+_SOURCE = "herdr:hermes"
+_AGENT = "hermes"
+
+
+def _base_params() -> tuple[str, str] | None:
+ if os.environ.get("HERDR_ENV") != "1":
+ return None
+ pane_id = os.environ.get("HERDR_PANE_ID", "").strip()
+ socket_path = os.environ.get("HERDR_SOCKET_PATH", "").strip()
+ if not pane_id or not socket_path:
+ return None
+ return pane_id, socket_path
+
+
+def _send(method: str, params: dict) -> None:
+ base = _base_params()
+ if base is None:
+ return
+ pane_id, socket_path = base
+ params = {
+ "pane_id": pane_id,
+ "source": _SOURCE,
+ "agent": _AGENT,
+ "seq": time.time_ns(),
+ **params,
+ }
+ request = {
+ "id": f"{_SOURCE}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}",
+ "method": method,
+ "params": params,
+ }
+ try:
+ client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ client.settimeout(0.5)
+ client.connect(socket_path)
+ client.sendall((json.dumps(request) + "\n").encode("utf-8"))
+ try:
+ client.recv(4096)
+ except Exception:
+ pass
+ client.close()
+ except Exception:
+ pass
+
+
+def _report(state: str) -> None:
+ _send("pane.report_agent", {"state": state})
+
+
+def _release() -> None:
+ _send("pane.release_agent", {})
+
+
+def _working(**kwargs) -> None:
+ del kwargs
+ _report("working")
+
+
+def _blocked(**kwargs) -> None:
+ del kwargs
+ _report("blocked")
+
+
+def _idle(**kwargs) -> None:
+ del kwargs
+ _report("idle")
+
+
+def _finalize(**kwargs) -> None:
+ del kwargs
+ _release()
+
+
+def register(ctx):
+ ctx.register_hook("on_session_start", _idle)
+ 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_finalize", _finalize)
diff --git a/src/integration/assets/hermes/plugin.yaml b/src/integration/assets/hermes/plugin.yaml
new file mode 100644
index 00000000..282f3b34
--- /dev/null
+++ b/src/integration/assets/hermes/plugin.yaml
@@ -0,0 +1,3 @@
+name: herdr-agent-state
+version: "1.0"
+description: Report Hermes Agent lifecycle state to Herdr panes
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 88f673ed..309d81f7 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -25,6 +25,12 @@ const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
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 HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state";
+const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml";
+const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py";
+const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml");
+const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py");
+const HERMES_INTEGRATION_VERSION: u32 = 1;
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
#[derive(Debug)]
@@ -45,6 +51,12 @@ pub(crate) struct OpenCodeInstallPaths {
pub plugin_path: PathBuf,
}
+#[derive(Debug)]
+pub(crate) struct HermesInstallPaths {
+ pub plugin_dir: PathBuf,
+ pub config_path: PathBuf,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IntegrationStatus {
pub target: crate::api::schema::IntegrationTarget,
@@ -90,6 +102,14 @@ pub(crate) struct OpenCodeUninstallResult {
pub removed_plugin: bool,
}
+#[derive(Debug)]
+pub(crate) struct HermesUninstallResult {
+ pub plugin_dir: PathBuf,
+ pub config_path: PathBuf,
+ pub removed_plugin_dir: bool,
+ pub updated_config: bool,
+}
+
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()));
@@ -137,6 +157,19 @@ pub(crate) fn install_target(
installed.plugin_path.display()
)]
}
+ crate::api::schema::IntegrationTarget::Hermes => {
+ let installed = install_hermes()?;
+ vec![
+ format!(
+ "installed hermes integration plugin to {}",
+ installed.plugin_dir.display()
+ ),
+ format!(
+ "enabled hermes plugin in {}",
+ installed.config_path.display()
+ ),
+ ]
+ }
};
crate::logging::integration_action("install", integration_target_label(target), "ok");
@@ -233,6 +266,33 @@ pub(crate) fn uninstall_target(
)]
}
}
+ crate::api::schema::IntegrationTarget::Hermes => {
+ let result = uninstall_hermes()?;
+ let mut messages = Vec::new();
+ if result.removed_plugin_dir {
+ messages.push(format!(
+ "removed hermes integration plugin at {}",
+ result.plugin_dir.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no hermes integration plugin found at {}",
+ result.plugin_dir.display()
+ ));
+ }
+ if result.updated_config {
+ messages.push(format!(
+ "disabled hermes plugin in {}",
+ result.config_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no hermes plugin entry found in {}",
+ result.config_path.display()
+ ));
+ }
+ messages
+ }
};
crate::logging::integration_action("uninstall", integration_target_label(target), "ok");
@@ -247,6 +307,7 @@ pub(crate) fn integration_target_label(
crate::api::schema::IntegrationTarget::Claude => "claude",
crate::api::schema::IntegrationTarget::Codex => "codex",
crate::api::schema::IntegrationTarget::Opencode => "opencode",
+ crate::api::schema::IntegrationTarget::Hermes => "hermes",
}
}
@@ -270,7 +331,7 @@ fn integration_specs() -> [(
crate::api::schema::IntegrationTarget,
io::Result<PathBuf>,
u32,
-); 4] {
+); 5] {
[
(
crate::api::schema::IntegrationTarget::Pi,
@@ -292,6 +353,11 @@ fn integration_specs() -> [(
opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)),
OPENCODE_INTEGRATION_VERSION,
),
+ (
+ crate::api::schema::IntegrationTarget::Hermes,
+ hermes_plugin_dir().map(|dir| dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)),
+ HERMES_INTEGRATION_VERSION,
+ ),
]
}
@@ -590,6 +656,43 @@ pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> {
Ok(OpenCodeInstallPaths { plugin_path })
}
+pub(crate) fn install_hermes() -> io::Result<HermesInstallPaths> {
+ let dir = hermes_dir()?;
+ if !dir.is_dir() {
+ return Err(io::Error::other(format!(
+ "hermes config directory not found at {}. install hermes agent first",
+ dir.display()
+ )));
+ }
+
+ let plugin_dir = hermes_plugin_dir()?;
+ fs::create_dir_all(&plugin_dir)?;
+ fs::write(
+ plugin_dir.join(HERMES_PLUGIN_MANIFEST_INSTALL_NAME),
+ HERMES_PLUGIN_MANIFEST_ASSET,
+ )?;
+ fs::write(
+ plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
+ HERMES_PLUGIN_INIT_ASSET,
+ )?;
+
+ let config_path = dir.join("config.yaml");
+ let existing_config = if config_path.is_file() {
+ fs::read_to_string(&config_path)?
+ } else {
+ String::new()
+ };
+ let new_config = ensure_hermes_plugin_enabled(&existing_config);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ }
+
+ Ok(HermesInstallPaths {
+ plugin_dir,
+ config_path,
+ })
+}
+
pub(crate) fn uninstall_pi() -> io::Result<PiUninstallResult> {
let extension_path = pi_extension_dir()?.join(PI_EXTENSION_INSTALL_NAME);
let removed_extension = remove_file_if_exists(&extension_path)?;
@@ -742,6 +845,30 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> {
})
}
+pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> {
+ let dir = hermes_dir()?;
+ let plugin_dir = hermes_plugin_dir()?;
+ let config_path = dir.join("config.yaml");
+
+ let removed_plugin_dir = remove_dir_all_if_exists(&plugin_dir)?;
+ let mut updated_config = false;
+ if config_path.is_file() {
+ let existing_config = fs::read_to_string(&config_path)?;
+ let new_config = remove_hermes_plugin_enabled(&existing_config);
+ if new_config != existing_config {
+ fs::write(&config_path, new_config)?;
+ updated_config = true;
+ }
+ }
+
+ Ok(HermesUninstallResult {
+ plugin_dir,
+ config_path,
+ removed_plugin_dir,
+ updated_config,
+ })
+}
+
fn ensure_hooks_object<'a>(
settings: &'a mut Value,
settings_path: &Path,
@@ -891,6 +1018,136 @@ fn remove_file_if_exists(path: &Path) -> io::Result<bool> {
}
}
+fn remove_dir_all_if_exists(path: &Path) -> io::Result<bool> {
+ match fs::remove_dir_all(path) {
+ Ok(()) => Ok(true),
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
+ Err(err) => Err(err),
+ }
+}
+
+fn ensure_hermes_plugin_enabled(content: &str) -> String {
+ update_hermes_enabled_plugin(content, true)
+}
+
+fn remove_hermes_plugin_enabled(content: &str) -> String {
+ update_hermes_enabled_plugin(content, false)
+}
+
+fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> String {
+ let trailing_newline = content.ends_with('\n');
+ let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
+ let Some(plugins_index) = top_level_yaml_key_index(&lines, "plugins") else {
+ if !enabled {
+ return content.to_string();
+ }
+ let mut result = content.trim_end_matches('\n').to_string();
+ if !result.is_empty() {
+ result.push('\n');
+ }
+ result.push_str("plugins:\n enabled:\n - herdr-agent-state\n");
+ return result;
+ };
+
+ let plugins_end =
+ next_top_level_yaml_key_index(&lines, plugins_index + 1).unwrap_or(lines.len());
+ let enabled_index = lines[plugins_index + 1..plugins_end]
+ .iter()
+ .position(|line| yaml_key_at_indent(line, 2) == Some("enabled"))
+ .map(|offset| plugins_index + 1 + offset);
+
+ if let Some(enabled_index) = enabled_index {
+ let line = lines[enabled_index].trim();
+ if line == "enabled: []" || line == "enabled: [] # herdr" {
+ if enabled {
+ lines[enabled_index] = " enabled:".to_string();
+ lines.insert(enabled_index + 1, " - herdr-agent-state".to_string());
+ }
+ return join_yaml_lines(lines, trailing_newline);
+ }
+
+ let list_start = enabled_index + 1;
+ let list_end = lines[list_start..plugins_end]
+ .iter()
+ .position(|line| {
+ yaml_indent(line).is_some_and(|indent| indent <= 2) && yaml_key_name(line).is_some()
+ })
+ .map(|offset| list_start + offset)
+ .unwrap_or(plugins_end);
+ let existing_item_index = lines[list_start..list_end]
+ .iter()
+ .position(|line| yaml_list_item_value(line) == Some(HERMES_PLUGIN_INSTALL_NAME))
+ .map(|offset| list_start + offset);
+
+ match (enabled, existing_item_index) {
+ (true, Some(_)) | (false, None) => return content.to_string(),
+ (true, None) => lines.insert(list_start, " - herdr-agent-state".to_string()),
+ (false, Some(index)) => {
+ lines.remove(index);
+ }
+ }
+ return join_yaml_lines(lines, trailing_newline);
+ }
+
+ if enabled {
+ lines.insert(plugins_index + 1, " enabled:".to_string());
+ lines.insert(plugins_index + 2, " - herdr-agent-state".to_string());
+ return join_yaml_lines(lines, trailing_newline);
+ }
+
+ content.to_string()
+}
+
+fn top_level_yaml_key_index(lines: &[String], key: &str) -> Option<usize> {
+ lines
+ .iter()
+ .position(|line| yaml_key_at_indent(line, 0) == Some(key))
+}
+
+fn next_top_level_yaml_key_index(lines: &[String], start: usize) -> Option<usize> {
+ lines[start..]
+ .iter()
+ .position(|line| yaml_indent(line) == Some(0) && yaml_key_name(line).is_some())
+ .map(|offset| start + offset)
+}
+
+fn yaml_key_at_indent(line: &str, indent: usize) -> Option<&str> {
+ if yaml_indent(line)? != indent {
+ return None;
+ }
+ yaml_key_name(line)
+}
+
+fn yaml_key_name(line: &str) -> Option<&str> {
+ let trimmed = line.trim_start();
+ if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
+ return None;
+ }
+ let (key, _) = trimmed.split_once(':')?;
+ let key = key.trim();
+ (!key.is_empty()).then_some(key)
+}
+
+fn yaml_indent(line: &str) -> Option<usize> {
+ let trimmed = line.trim_start();
+ if trimmed.is_empty() || trimmed.starts_with('#') {
+ return None;
+ }
+ Some(line.len() - trimmed.len())
+}
+
+fn yaml_list_item_value(line: &str) -> Option<&str> {
+ line.trim().strip_prefix("- ").map(str::trim)
+}
+
+fn join_yaml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
+ let mut result = lines.join("\n");
+ if trailing_newline || result.is_empty() {
+ result.push('\n');
+ }
+ result
+}
+
fn build_codex_config_with_hooks(content: &str) -> String {
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let trailing_newline = content.ends_with('\n');
@@ -1053,6 +1310,16 @@ fn opencode_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".config/opencode"))
}
+fn hermes_dir() -> io::Result<PathBuf> {
+ Ok(home_dir()?.join(".hermes"))
+}
+
+fn hermes_plugin_dir() -> io::Result<PathBuf> {
+ Ok(hermes_dir()?
+ .join("plugins")
+ .join(HERMES_PLUGIN_INSTALL_NAME))
+}
+
fn home_dir() -> io::Result<PathBuf> {
std::env::var("HOME")
.map(PathBuf::from)
@@ -1695,4 +1962,110 @@ mod tests {
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
+
+ #[test]
+ fn install_hermes_writes_plugin_and_enables_it() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ fs::create_dir_all(&hermes_dir).unwrap();
+ fs::write(hermes_dir.join("config.yaml"), "model:\n provider: auto\n").unwrap();
+ std::env::set_var("HOME", &home);
+
+ let installed = install_hermes().unwrap();
+ let manifest = fs::read_to_string(
+ installed
+ .plugin_dir
+ .join(HERMES_PLUGIN_MANIFEST_INSTALL_NAME),
+ )
+ .unwrap();
+ let init =
+ fs::read_to_string(installed.plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)).unwrap();
+ let config = fs::read_to_string(&installed.config_path).unwrap();
+
+ assert_eq!(
+ installed.plugin_dir,
+ hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME)
+ );
+ assert_eq!(manifest, HERMES_PLUGIN_MANIFEST_ASSET);
+ assert_eq!(init, HERMES_PLUGIN_INIT_ASSET);
+ assert!(config.contains("plugins:\n enabled:\n - herdr-agent-state"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_hermes_is_idempotent_for_enabled_entry() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ fs::create_dir_all(&hermes_dir).unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins:\n enabled:\n - herdr-agent-state\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ install_hermes().unwrap();
+ install_hermes().unwrap();
+
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+ assert_eq!(config.matches("herdr-agent-state").count(), 1);
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn uninstall_hermes_removes_plugin_and_enabled_entry() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ let hermes_dir = home.join(".hermes");
+ let plugin_dir = hermes_dir.join("plugins").join(HERMES_PLUGIN_INSTALL_NAME);
+ fs::create_dir_all(&plugin_dir).unwrap();
+ fs::write(
+ plugin_dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME),
+ HERMES_PLUGIN_INIT_ASSET,
+ )
+ .unwrap();
+ fs::write(
+ hermes_dir.join("config.yaml"),
+ "plugins:\n enabled:\n - other-plugin\n - herdr-agent-state\n",
+ )
+ .unwrap();
+ std::env::set_var("HOME", &home);
+
+ let result = uninstall_hermes().unwrap();
+ let config = fs::read_to_string(hermes_dir.join("config.yaml")).unwrap();
+
+ assert!(result.removed_plugin_dir);
+ assert!(result.updated_config);
+ assert!(!plugin_dir.exists());
+ assert!(config.contains(" - other-plugin"));
+ assert!(!config.contains("herdr-agent-state"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
+
+ #[test]
+ fn install_hermes_errors_when_config_dir_missing() {
+ let _lock = integration_env_lock();
+ let base = unique_base();
+ let home = base.join("home");
+ fs::create_dir_all(&home).unwrap();
+ std::env::set_var("HOME", &home);
+
+ let err = install_hermes().unwrap_err().to_string();
+
+ assert!(err.contains("hermes config directory not found"));
+
+ std::env::remove_var("HOME");
+ let _ = fs::remove_dir_all(base);
+ }
}