aboutsummaryrefslogtreecommitdiff
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
parent19295b85e5d6c2c7a9bce5d52343d89749d9f628 (diff)
feat: support hermes agent integration
refs #144
-rw-r--r--docs/next/CHANGELOG.md5
-rw-r--r--docs/next/CONFIGURATION.md1
-rw-r--r--docs/next/INTEGRATIONS.md44
-rw-r--r--docs/next/README.md6
-rw-r--r--src/api/schema.rs1
-rw-r--r--src/cli.rs8
-rw-r--r--src/config/sound.rs3
-rw-r--r--src/detect.rs78
-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
-rw-r--r--src/server/protocol.rs2
-rw-r--r--src/terminal/state.rs8
-rw-r--r--tests/api_ping.rs2
-rw-r--r--tests/cli_wrapper.rs6
-rw-r--r--tests/client_mode.rs36
-rw-r--r--tests/cross_area.rs16
-rw-r--r--tests/detach_reattach.rs44
-rw-r--r--tests/multi_client.rs2
-rw-r--r--tests/server_headless.rs16
-rw-r--r--website/src/content/docs/agents.mdx2
-rw-r--r--website/src/content/docs/cli-reference.mdx2
-rw-r--r--website/src/content/docs/integrations.mdx16
23 files changed, 700 insertions, 73 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index c7df66e4..7e60e568 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -3,8 +3,13 @@
## Unreleased
### Added
+- Added Hermes Agent foreground-process detection with basic idle, working, and blocked heuristics.
+- Added a Hermes Agent plugin integration for direct state reporting.
- Added `ui.sidebar_min_width` and `ui.sidebar_max_width` to configure the sidebar's expanded resize bounds. Defaults remain 18 and 36 columns; existing configs are unchanged.
+### Breaking Changes
+- The client/server protocol is now version 7. Stop and restart any running v0.5.10 server before attaching with this release.
+
## [0.5.10] - 2026-05-17
### Added
diff --git a/docs/next/CONFIGURATION.md b/docs/next/CONFIGURATION.md
index 4425cc59..9d85b53b 100644
--- a/docs/next/CONFIGURATION.md
+++ b/docs/next/CONFIGURATION.md
@@ -409,6 +409,7 @@ available agent keys:
- `droid`
- `amp`
- `grok`
+- `hermes`
## experimental
diff --git a/docs/next/INTEGRATIONS.md b/docs/next/INTEGRATIONS.md
index 23b41ab8..fc47bbc0 100644
--- a/docs/next/INTEGRATIONS.md
+++ b/docs/next/INTEGRATIONS.md
@@ -12,6 +12,7 @@ if you want to inspect the exact files herdr installs, they are versioned in thi
- [claude code hook](./src/integration/assets/claude/herdr-agent-state.sh)
- [codex hook](./src/integration/assets/codex/herdr-agent-state.sh)
- [opencode plugin](./src/integration/assets/opencode/herdr-agent-state.js)
+- [hermes plugin](./src/integration/assets/hermes/__init__.py)
## how herdr uses integrations
@@ -30,6 +31,7 @@ herdr integration install pi
herdr integration install claude
herdr integration install codex
herdr integration install opencode
+herdr integration install hermes
```
## uninstall commands
@@ -39,6 +41,7 @@ herdr integration uninstall pi
herdr integration uninstall claude
herdr integration uninstall codex
herdr integration uninstall opencode
+herdr integration uninstall hermes
```
## pi
@@ -210,6 +213,47 @@ this removes:
~/.config/opencode/plugins/herdr-agent-state.js
```
+## hermes agent
+
+install:
+
+```bash
+herdr integration install hermes
+```
+
+this:
+
+- writes the bundled plugin to `~/.hermes/plugins/herdr-agent-state/`
+- updates `~/.hermes/config.yaml` so `plugins.enabled` contains `herdr-agent-state`
+
+bundled source: [`src/integration/assets/hermes/__init__.py`](./src/integration/assets/hermes/__init__.py)
+
+current plugin mapping:
+
+- `on_session_start` → `idle`
+- `pre_llm_call` → `working`
+- `pre_api_request` → `working`
+- `pre_tool_call` → `working`
+- `post_tool_call` → `working`
+- `pre_approval_request` → `blocked`
+- `post_approval_response` → `working`
+- `post_llm_call` → `idle`
+- `on_session_end` → `idle`
+- `on_session_finalize` → `release`
+
+notes:
+
+- hermes plugins are opt-in, so install also enables the plugin in `config.yaml`.
+- herdr still keeps native process detection and screen heuristics for hermes. the plugin improves state accuracy when the hook surface fires.
+
+uninstall:
+
+```bash
+herdr integration uninstall hermes
+```
+
+this removes `~/.hermes/plugins/herdr-agent-state/` and removes the matching `plugins.enabled` entry from `~/.hermes/config.yaml`.
+
## grok cli
herdr does not currently install a grok hook or plugin.
diff --git a/docs/next/README.md b/docs/next/README.md
index 808cc1a8..5ace7525 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -178,6 +178,7 @@ automatic detection works out of the box. process name matching plus terminal ou
| [amp](https://ampcode.com) | ✓ | ✓ | ✓ |
| [opencode](https://github.com/anomalyco/opencode) | ✓ | ✓ | ✓ |
| [grok cli](https://x.ai/grok) | ✓ | ✓ | ✓ |
+| [hermes agent](https://github.com/NousResearch/hermes-agent) | ✓ | ✓ | ✓ |
detected but not fully tested: gemini cli, cursor agent, cline, kimi, github copilot cli.
@@ -185,13 +186,14 @@ for agents outside the built-in list, herdr still works as a terminal multiplexe
### direct integrations
-the built-in pi, claude code, codex, and opencode integrations forward semantic state to herdr over the socket api. install with:
+the built-in pi, claude code, codex, opencode, and hermes integrations forward semantic state to herdr over the socket api. install with:
```bash
herdr integration install pi
herdr integration install claude
herdr integration install codex
herdr integration install opencode
+herdr integration install hermes
```
see [`INTEGRATIONS.md`](./INTEGRATIONS.md) for setup details.
@@ -263,7 +265,7 @@ full logging and environment variable details: [`CONFIGURATION.md`](./CONFIGURAT
## docs
- [`CONFIGURATION.md`](./CONFIGURATION.md) — keybindings, themes, notifications, environment variables
-- [`INTEGRATIONS.md`](./INTEGRATIONS.md) — pi, claude code, codex, opencode integrations
+- [`INTEGRATIONS.md`](./INTEGRATIONS.md) — pi, claude code, codex, opencode, hermes integrations
- [`SKILL.md`](./SKILL.md) — reusable agent skill
- [`SOCKET_API.md`](./SOCKET_API.md) — socket protocol and cli reference
diff --git a/src/api/schema.rs b/src/api/schema.rs
index a82070e0..7cf73cda 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -397,6 +397,7 @@ pub enum IntegrationTarget {
Claude,
Codex,
Opencode,
+ Hermes,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/src/cli.rs b/src/cli.rs
index e9315c3d..f57cc625 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1627,11 +1627,11 @@ fn parse_integration_target(
action: &str,
) -> std::io::Result<Option<IntegrationTarget>> {
let Some(target) = args.first().map(|arg| arg.as_str()) else {
- eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode>");
+ eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>");
return Ok(None);
};
if args.len() != 1 {
- eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode>");
+ eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode|hermes>");
return Ok(None);
}
@@ -1640,9 +1640,10 @@ fn parse_integration_target(
"claude" => IntegrationTarget::Claude,
"codex" => IntegrationTarget::Codex,
"opencode" => IntegrationTarget::Opencode,
+ "hermes" => IntegrationTarget::Hermes,
_ => {
eprintln!("unknown integration target: {target}");
- eprintln!("currently supported: pi, claude, codex, opencode");
+ eprintln!("currently supported: pi, claude, codex, opencode, hermes");
return Ok(None);
}
};
@@ -2145,6 +2146,7 @@ fn print_integration_help() {
eprintln!(" herdr integration install claude");
eprintln!(" herdr integration install codex");
eprintln!(" herdr integration install opencode");
+ eprintln!(" herdr integration install hermes");
eprintln!(" herdr integration uninstall pi");
eprintln!(" herdr integration uninstall claude");
eprintln!(" herdr integration uninstall codex");
diff --git a/src/config/sound.rs b/src/config/sound.rs
index d4e279c3..09d85c18 100644
--- a/src/config/sound.rs
+++ b/src/config/sound.rs
@@ -37,6 +37,7 @@ pub struct AgentSoundOverrides {
pub droid: AgentSoundSetting,
pub amp: AgentSoundSetting,
pub grok: AgentSoundSetting,
+ pub hermes: AgentSoundSetting,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
@@ -124,6 +125,7 @@ impl AgentSoundOverrides {
Some(Agent::Droid) => self.droid,
Some(Agent::Amp) => self.amp,
Some(Agent::Grok) => self.grok,
+ Some(Agent::Hermes) => self.hermes,
None => AgentSoundSetting::Default,
}
}
@@ -156,6 +158,7 @@ impl Default for AgentSoundOverrides {
droid: AgentSoundSetting::Off,
amp: AgentSoundSetting::Default,
grok: AgentSoundSetting::Default,
+ hermes: AgentSoundSetting::Default,
}
}
}
diff --git a/src/detect.rs b/src/detect.rs
index 5d890b90..0319b4ef 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -31,6 +31,7 @@ pub enum Agent {
Droid,
Amp,
Grok,
+ Hermes,
}
pub fn agent_label(agent: Agent) -> &'static str {
@@ -47,6 +48,7 @@ pub fn agent_label(agent: Agent) -> &'static str {
Agent::Droid => "droid",
Agent::Amp => "amp",
Agent::Grok => "grok",
+ Agent::Hermes => "hermes",
}
}
@@ -65,6 +67,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> {
"droid" => Some(Agent::Droid),
"amp" | "amp-local" => Some(Agent::Amp),
"grok" | "grok-build" => Some(Agent::Grok),
+ "hermes" | "hermes-agent" => Some(Agent::Hermes),
_ => None,
}
}
@@ -87,6 +90,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> {
"droid" => Some(Agent::Droid),
"amp" | "amp-local" => Some(Agent::Amp),
"grok" | "grok-build" => Some(Agent::Grok),
+ "hermes" | "hermes-agent" => Some(Agent::Hermes),
_ => None,
}
}
@@ -129,6 +133,7 @@ pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState {
Agent::Droid => detect_droid(screen_content),
Agent::Amp => detect_amp(screen_content),
Agent::Grok => detect_grok(screen_content),
+ Agent::Hermes => detect_hermes(screen_content),
}
}
@@ -461,6 +466,31 @@ fn detect_grok(content: &str) -> AgentState {
AgentState::Idle
}
+/// Hermes Agent detection.
+///
+/// Hermes shows a bottom status bar while turns are active and modal approval
+/// dialogs for dangerous terminal commands. Prefer the modal controls for
+/// blocked detection, then the live interrupt/status controls for working.
+fn detect_hermes(content: &str) -> AgentState {
+ let lower = content.to_lowercase();
+
+ let has_approval_options = lower.contains("allow once")
+ && lower.contains("allow for this session")
+ && lower.contains("deny");
+ let has_approval_controls = lower.contains("enter to confirm")
+ || lower.contains("↑/↓ to select")
+ || lower.contains("show full command");
+ if (lower.contains("dangerous command") || has_approval_options) && has_approval_controls {
+ return AgentState::Blocked;
+ }
+
+ if lower.contains("msg=interrupt") || lower.contains("ctrl+c cancel") {
+ return AgentState::Working;
+ }
+
+ AgentState::Idle
+}
+
/// Check for braille spinner characters at the start of a line.
/// These are the Unicode braille pattern dots used by CLI spinners.
fn has_braille_spinner(content: &str) -> bool {
@@ -731,6 +761,8 @@ mod tests {
assert_eq!(identify_agent("ghcs"), Some(Agent::GithubCopilot));
assert_eq!(identify_agent("grok"), Some(Agent::Grok));
assert_eq!(identify_agent("grok-build"), Some(Agent::Grok));
+ assert_eq!(identify_agent("hermes"), Some(Agent::Hermes));
+ assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes));
}
#[test]
@@ -744,6 +776,7 @@ mod tests {
);
assert_eq!(parse_agent_label("amp-local"), Some(Agent::Amp));
assert_eq!(parse_agent_label("grok-build"), Some(Agent::Grok));
+ assert_eq!(parse_agent_label("hermes-agent"), Some(Agent::Hermes));
}
#[test]
@@ -752,6 +785,7 @@ mod tests {
assert_eq!(agent_label(Agent::GithubCopilot), "copilot");
assert_eq!(agent_label(Agent::OpenCode), "opencode");
assert_eq!(agent_label(Agent::Grok), "grok");
+ assert_eq!(agent_label(Agent::Hermes), "hermes");
}
#[test]
@@ -1405,6 +1439,50 @@ mod tests {
assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle);
}
+ // ---- Hermes ----
+
+ #[test]
+ fn hermes_identified_by_process_name() {
+ assert_eq!(identify_agent("hermes"), Some(Agent::Hermes));
+ assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes));
+ }
+
+ #[test]
+ fn hermes_working_on_interrupt_footer() {
+ let screen = " (⌐■_■) computing...\n\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏱ 3s\n─────────────────────────────────────────────────────────────────────────────────────────\n⚕ ❯ msg=interrupt · /queue · /bg · /steer · Ctrl+C cancel";
+ assert_eq!(
+ detect_state(Some(Agent::Hermes), screen),
+ AgentState::Working
+ );
+ }
+
+ #[test]
+ fn hermes_idle_ignores_stale_initializing_agent_text() {
+ let screen = "● say exactly READY and stop\nInitializing agent...\n\n╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯";
+ assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
+ }
+
+ #[test]
+ fn hermes_blocked_on_dangerous_command_prompt() {
+ let screen = "╭────────────────────────────────────────────────────────────╮\n│ ⚠️ Dangerous Command │\n│ mkdir -p /tmp/herdr-hermes-block-test/subdir && touch │\n│ ❯ 1. Allow once │\n│ 2. Allow for this session │\n│ 3. Add to permanent allowlist │\n│ 4. Deny │\n│ 5. Show full command │\n╰────────────────────────────────────────────────────────────╯\n ↑/↓ to select, Enter to confirm\n⚠ ❯";
+ assert_eq!(
+ detect_state(Some(Agent::Hermes), screen),
+ AgentState::Blocked
+ );
+ }
+
+ #[test]
+ fn hermes_idle_at_prompt_after_response() {
+ let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n READY\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.5K/272K │ [█░░░░░░░░░] 6% │ 15s │ ⏲ 2s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯\n─────────────────────────────────────────────────────────────────────────────────────────";
+ assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
+ }
+
+ #[test]
+ fn hermes_denied_message_is_idle() {
+ let screen = "╭─ ⚕ Hermes ────────────────────────────────────────────────────────────────────────────╮\n Command was blocked/denied by the safety layer. I did not retry.\n╰───────────────────────────────────────────────────────────────────────────────────────╯\n ⚕ gpt-5.5 │ 15.4K/272K │ [█░░░░░░░░░] 6% │ 2m │ ⏲ 11s\n─────────────────────────────────────────────────────────────────────────────────────────\n❯";
+ assert_eq!(detect_state(Some(Agent::Hermes), screen), AgentState::Idle);
+ }
+
// ---- Helpers ----
#[test]
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);
+ }
}
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index 6745b0db..abc3abec 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 6;
+pub const PROTOCOL_VERSION: u32 = 7;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 8c27f1a5..5b2d956d 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -428,14 +428,14 @@ mod tests {
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority(
"herdr:custom".into(),
- "hermes".into(),
+ "custom-agent".into(),
AgentState::Working,
None,
None,
);
assert_eq!(terminal.detected_agent, Some(Agent::Pi));
- assert_eq!(terminal.effective_agent_label(), Some("hermes"));
+ assert_eq!(terminal.effective_agent_label(), Some("custom-agent"));
assert_eq!(terminal.effective_known_agent(), None);
assert_eq!(terminal.state, AgentState::Working);
}
@@ -504,7 +504,7 @@ mod tests {
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority(
"herdr:custom".into(),
- "hermes".into(),
+ "custom-agent".into(),
AgentState::Working,
None,
None,
@@ -514,7 +514,7 @@ mod tests {
assert!(terminal.hook_authority.is_some());
assert_eq!(terminal.detected_agent, None);
- assert_eq!(terminal.effective_agent_label(), Some("hermes"));
+ assert_eq!(terminal.effective_agent_label(), Some("custom-agent"));
assert_eq!(terminal.state, AgentState::Working);
}
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index 066c9179..d01b1efa 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -227,7 +227,7 @@ fn ping_over_socket_returns_version() {
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
// Intentionally hardcoded so wire protocol bumps require updating this test.
// Changing this value means old clients/servers are no longer compatible.
- assert_eq!(value["result"]["protocol"], 6);
+ assert_eq!(value["result"]["protocol"], 7);
cleanup_spawned_herdr(child, base);
}
diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs
index d43f52ef..affcb89a 100644
--- a/tests/cli_wrapper.rs
+++ b/tests/cli_wrapper.rs
@@ -936,7 +936,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
- full_stdout.contains(" protocol: 6"),
+ full_stdout.contains(" protocol: 7"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
@@ -969,7 +969,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
- server_stdout.contains("protocol: 6"),
+ server_stdout.contains("protocol: 7"),
"stdout: {server_stdout}"
);
@@ -981,7 +981,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
- client_stdout.contains("protocol: 6"),
+ client_stdout.contains("protocol: 7"),
"stdout: {client_stdout}"
);
assert!(
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index 79dfaae3..83bc3638 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -267,8 +267,8 @@ fn client_connects_and_receives_frame() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6, "server should report protocol version 6");
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7, "server should report protocol version 7");
assert!(
error.is_none(),
"handshake should not have error: {:?}",
@@ -335,8 +335,8 @@ fn client_sees_headless_startup_config_diagnostic() {
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
stream
@@ -384,8 +384,8 @@ fn client_input_forwarded_to_pane() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Send an Input message containing "echo hello\n".
@@ -438,8 +438,8 @@ fn client_resize_sends_message() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Drain the initial frame(s).
@@ -497,8 +497,8 @@ fn server_shutdown_sends_message_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Send SIGINT so the server takes the graceful shutdown path and
@@ -725,8 +725,8 @@ fn client_receives_frame_after_pane_output() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
read_next_frame_payload(&mut stream, Duration::from_secs(10))
@@ -772,8 +772,8 @@ fn navigate_mode_keybind_dispatch_in_server() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -890,8 +890,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
@@ -988,8 +988,8 @@ fn client_receives_notify_on_agent_state_change() {
// Connect as a client and perform handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index b5910e92..d57a19c1 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -678,7 +678,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Local attach (client A).
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 6, 100, 30);
+ client_handshake(&mut client_a, 7, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
// Use herdr: create a workspace and write output into its pane.
@@ -715,7 +715,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Reattach from another terminal/session (client B).
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 6, 80, 24);
+ client_handshake(&mut client_b, 7, 80, 24);
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(5)),
"reattached client should receive frame"
@@ -771,7 +771,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 6, 100, 30);
+ client_handshake(&mut client_a, 7, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
let created = workspace_create(&api_socket, "agent-persist");
@@ -824,7 +824,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
// Reattach and ensure client-side state reflects the persisted working status.
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 6, 80, 24);
+ client_handshake(&mut client_b, 7, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
frame_contains_text(frame, "working")
@@ -869,7 +869,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
- client_handshake(&mut client, 6, 100, 30);
+ client_handshake(&mut client, 7, 100, 30);
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
drain_server_messages(&mut client, Duration::from_millis(300));
@@ -932,9 +932,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 6, 110, 30);
+ client_handshake(&mut client_a, 7, 110, 30);
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 6, 100, 30);
+ client_handshake(&mut client_b, 7, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
@@ -1100,7 +1100,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
let mut reconnect_client =
UnixStream::connect(&client_socket).expect("new client should connect after restart");
- client_handshake(&mut reconnect_client, 6, 80, 24);
+ client_handshake(&mut reconnect_client, 7, 80, 24);
assert!(
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
"new client should receive frame after restart"
diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs
index 67e634a7..a66d35c9 100644
--- a/tests/detach_reattach.rs
+++ b/tests/detach_reattach.rs
@@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -397,8 +397,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client A ---
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream_a, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -436,8 +436,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream_b, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(
error.is_none(),
"reattach handshake should succeed: {:?}",
@@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -555,8 +555,8 @@ fn processes_survive_during_and_after_detach() {
// Reattach — verify we can connect and receive a frame.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 6, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream_b, 7, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Verify the reattached client receives a frame.
@@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -631,8 +631,8 @@ fn server_persists_after_client_connection_drop() {
// Reattach — verify we can connect and handshake again.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 6, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream_b, 7, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "reattach should succeed: {:?}", error);
cleanup_spawned_herdr(spawned, base);
@@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() {
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
let (version, error) =
- client_handshake(&mut stream, 6, 120, 40).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream, 7, 120, 40).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
drain_messages(&mut stream);
@@ -722,8 +722,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// Connect and handshake client A.
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 6, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream_a, 7, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Detach client A immediately.
@@ -780,8 +780,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 6, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 6);
+ client_handshake(&mut stream_b, 7, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 7);
assert!(error.is_none(), "{:?}", error);
// Client B should receive a frame with the current state.
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
index 103c3f1f..c808f057 100644
--- a/tests/multi_client.rs
+++ b/tests/multi_client.rs
@@ -549,7 +549,7 @@ fn client_handshake(
fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream {
let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket");
- client_handshake(&mut stream, 6, cols, rows).expect("handshake should succeed");
+ client_handshake(&mut stream, 7, cols, rows).expect("handshake should succeed");
stream
}
diff --git a/tests/server_headless.rs b/tests/server_headless.rs
index 51a9d005..9ea47024 100644
--- a/tests/server_headless.rs
+++ b/tests/server_headless.rs
@@ -583,11 +583,11 @@ fn client_handshake_succeeds() {
// Connect to the client socket and perform a handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- // Send Hello with version 6, 80 cols, 24 rows.
+ // Send Hello with version 7, 80 cols, 24 rows.
let (version, error) =
- client_handshake(&mut stream, 6, 80, 24).expect("handshake should succeed");
+ client_handshake(&mut stream, 7, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 6, "server should report protocol version 6");
+ assert_eq!(version, 7, "server should report protocol version 7");
assert!(
error.is_none(),
"handshake should not have an error: {:?}",
@@ -616,7 +616,7 @@ fn client_handshake_rejects_incompatible_version() {
let (version, error) = client_handshake(&mut stream, 0, 80, 24)
.expect("should read Welcome response even on rejection");
- assert_eq!(version, 6, "server should report its version 6");
+ assert_eq!(version, 7, "server should report its version 7");
assert!(
error.is_some(),
"version 0 should be rejected with an error"
@@ -641,10 +641,10 @@ fn client_handshake_clamps_small_terminal_size() {
// Send Hello with 0x0 terminal size — should be clamped.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) = client_handshake(&mut stream, 6, 0, 0)
+ let (version, error) = client_handshake(&mut stream, 7, 0, 0)
.expect("handshake with 0x0 should succeed (server clamps)");
- assert_eq!(version, 6);
+ assert_eq!(version, 7);
assert!(
error.is_none(),
"0x0 size should be accepted (clamped): {:?}",
@@ -704,9 +704,9 @@ fn no_hello_client_closed_within_five_seconds() {
// Verify the server is still healthy — a proper client can still connect.
let mut good_stream =
UnixStream::connect(&client_socket).expect("should connect after no-hello client");
- let (version, error) = client_handshake(&mut good_stream, 6, 80, 24)
+ let (version, error) = client_handshake(&mut good_stream, 7, 80, 24)
.expect("proper handshake should still work after no-hello client");
- assert_eq!(version, 6);
+ assert_eq!(version, 7);
assert!(error.is_none());
// API should still work.
diff --git a/website/src/content/docs/agents.mdx b/website/src/content/docs/agents.mdx
index ae21defe..e3d2bc0c 100644
--- a/website/src/content/docs/agents.mdx
+++ b/website/src/content/docs/agents.mdx
@@ -18,6 +18,7 @@ Automatic detection works out of the box for common coding agents.
| Amp | yes | yes | yes |
| OpenCode | yes | yes | yes |
| Grok CLI | yes | yes | yes |
+| Hermes Agent | yes | yes | yes |
| GitHub Copilot CLI | yes | yes | yes |
Detected but less thoroughly tested: Gemini CLI, Cursor Agent, Cline, and Kimi.
@@ -51,6 +52,7 @@ herdr integration install pi
herdr integration install claude
herdr integration install codex
herdr integration install opencode
+herdr integration install hermes
```
Check installed integration versions:
diff --git a/website/src/content/docs/cli-reference.mdx b/website/src/content/docs/cli-reference.mdx
index 78487b6a..57f495c1 100644
--- a/website/src/content/docs/cli-reference.mdx
+++ b/website/src/content/docs/cli-reference.mdx
@@ -165,10 +165,12 @@ herdr integration install pi
herdr integration install claude
herdr integration install codex
herdr integration install opencode
+herdr integration install hermes
herdr integration uninstall pi
herdr integration uninstall claude
herdr integration uninstall codex
herdr integration uninstall opencode
+herdr integration uninstall hermes
herdr integration status [--outdated-only]
```
diff --git a/website/src/content/docs/integrations.mdx b/website/src/content/docs/integrations.mdx
index 4af83c30..01e72e16 100644
--- a/website/src/content/docs/integrations.mdx
+++ b/website/src/content/docs/integrations.mdx
@@ -1,6 +1,6 @@
---
title: Integrations
-description: Install Herdr integrations for Pi, Claude Code, Codex, and OpenCode.
+description: Install Herdr integrations for Pi, Claude Code, Codex, OpenCode, and Hermes Agent.
---
Herdr detects supported agents automatically. Integrations make that detection more precise by reporting semantic state directly to Herdr.
@@ -14,6 +14,7 @@ herdr integration install pi
herdr integration install claude
herdr integration install codex
herdr integration install opencode
+herdr integration install hermes
```
## Uninstall integrations
@@ -23,6 +24,7 @@ herdr integration uninstall pi
herdr integration uninstall claude
herdr integration uninstall codex
herdr integration uninstall opencode
+herdr integration uninstall hermes
```
## How Herdr uses integrations
@@ -83,6 +85,18 @@ herdr integration install opencode
The plugin reports semantic state while OpenCode runs inside a Herdr pane.
+## Hermes Agent
+
+Install the Hermes Agent plugin:
+
+```bash
+herdr integration install hermes
+```
+
+Herdr writes `~/.hermes/plugins/herdr-agent-state/` and enables `herdr-agent-state` in `~/.hermes/config.yaml`. Restart Hermes after installing so the plugin loads.
+
+The plugin reports lifecycle, tool, and approval state while Hermes runs inside a Herdr pane. Native screen heuristics remain available when the plugin is not installed.
+
## Custom status labels
Integrations can report a short visual label without changing the semantic state.