diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-19 00:23:20 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-19 00:26:05 +0300 |
| commit | e7f94a3f359fe1be94a0863b731d94d245093848 (patch) | |
| tree | f89459235e9a163bbafc68fb485d9355b6fd1e3f | |
| parent | 19295b85e5d6c2c7a9bce5d52343d89749d9f628 (diff) | |
feat: support hermes agent integration
refs #144
| -rw-r--r-- | docs/next/CHANGELOG.md | 5 | ||||
| -rw-r--r-- | docs/next/CONFIGURATION.md | 1 | ||||
| -rw-r--r-- | docs/next/INTEGRATIONS.md | 44 | ||||
| -rw-r--r-- | docs/next/README.md | 6 | ||||
| -rw-r--r-- | src/api/schema.rs | 1 | ||||
| -rw-r--r-- | src/cli.rs | 8 | ||||
| -rw-r--r-- | src/config/sound.rs | 3 | ||||
| -rw-r--r-- | src/detect.rs | 78 | ||||
| -rw-r--r-- | src/integration/assets/hermes/__init__.py | 97 | ||||
| -rw-r--r-- | src/integration/assets/hermes/plugin.yaml | 3 | ||||
| -rw-r--r-- | src/integration/mod.rs | 375 | ||||
| -rw-r--r-- | src/server/protocol.rs | 2 | ||||
| -rw-r--r-- | src/terminal/state.rs | 8 | ||||
| -rw-r--r-- | tests/api_ping.rs | 2 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 6 | ||||
| -rw-r--r-- | tests/client_mode.rs | 36 | ||||
| -rw-r--r-- | tests/cross_area.rs | 16 | ||||
| -rw-r--r-- | tests/detach_reattach.rs | 44 | ||||
| -rw-r--r-- | tests/multi_client.rs | 2 | ||||
| -rw-r--r-- | tests/server_headless.rs | 16 | ||||
| -rw-r--r-- | website/src/content/docs/agents.mdx | 2 | ||||
| -rw-r--r-- | website/src/content/docs/cli-reference.mdx | 2 | ||||
| -rw-r--r-- | website/src/content/docs/integrations.mdx | 16 |
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)] @@ -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. |
