diff --git a/src/commands/completion.rs b/src/commands/completion.rs new file mode 100644 index 00000000..f0dc1aab --- /dev/null +++ b/src/commands/completion.rs @@ -0,0 +1,616 @@ +//! `hcom completion` command — generate shell completions. +//! +//! Currently supports Zsh. Output a completion script to stdout that the user +//! can source or install into their `$fpath`. + +use crate::db::HcomDb; +use crate::shared::CommandContext; + +#[derive(clap::Parser, Debug)] +#[command(name = "completion", about = "Generate shell completion scripts")] +pub struct CompletionArgs { + /// Shell to generate completions for (zsh) + pub shell: Option, + + /// Install completions (zsh: write to $fpath/_hcom) + #[arg(long)] + pub install: bool, +} + +/// Commands available at the top level (keep in sync with router.rs). +const CLI_COMMANDS: &[&str] = &[ + "send", + "list", + "events", + "stop", + "start", + "listen", + "status", + "config", + "hooks", + "archive", + "reset", + "transcript", + "bundle", + "kill", + "term", + "relay", + "run", + "update", +]; + +/// All tool names (released) + their public aliases. +fn all_tool_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = Vec::new(); + for spec in crate::integration_spec::ALL { + if !spec.released { + continue; + } + names.push(spec.name); + for alias in spec.aliases { + names.push(alias); + } + } + names +} + +/// Hook names across all released tools. +fn all_hook_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = Vec::new(); + for spec in crate::integration_spec::ALL { + if !spec.released { + continue; + } + for hook in spec.hooks.names { + if !names.contains(hook) { + names.push(hook); + } + } + } + names +} + +/// Generate the tool case arms for the Zsh completion function. +fn generate_tool_arms() -> String { + let mut arms = String::new(); + + for spec in crate::integration_spec::ALL { + if !spec.released { + continue; + } + let names: Vec<&str> = std::iter::once(spec.name) + .chain(spec.aliases.iter().copied()) + .collect(); + let case_pattern = names.join("|"); + + // Check if this tool has a per-tool args env var. + let has_args_env = spec.launch.args_env.is_some(); + // Only emit the --*-args flag for the canonical name. + let canonical = spec.name.replace('_', "-"); + + let extra = if has_args_env { + format!( + " \"--{}-args=[Default args]:args: \" \\\n", + canonical + ) + } else { + String::new() + }; + + let arm = format!( + r#" ({case_pattern}) + _arguments -s -S \ + "--tag=[Group tag]:tag:" \ + "--terminal=[Terminal preset]:preset:(default kitty wezterm tmux zellij iterm)" \ + "--dir=[Working directory]:directory:_files -/" \ + "--headless[Run in background]" \ + "--hcom-prompt=[Initial prompt]:prompt: " \ + "--hcom-system-prompt=[System prompt]:prompt: " \ + "--name=[Instance name]:name: " \ + "--go[Skip confirmation]" \ + {extra}\ + && return 0 + ;; +"#, + case_pattern = case_pattern, + extra = extra, + ); + arms.push_str(&arm); + } + arms +} + +/// Generate the Zsh completion function. +fn generate_zsh_completion() -> String { + let tools = all_tool_names(); + let commands = CLI_COMMANDS; + let hooks = all_hook_names(); + + let tool_list = tools.join(" "); + let command_list = commands.join(" "); + let hook_list = hooks.join(" "); + + let tool_arms = generate_tool_arms(); + + let cmd_entries: Vec = commands + .iter() + .map(|c| { + let desc = match *c { + "send" => "Send message to agents", + "list" => "List active agents", + "events" => "Query event stream", + "stop" => "Disconnect from hcom", + "start" => "Connect to hcom", + "listen" => "Block until message", + "status" => "System health overview", + "config" => "Get/set settings", + "hooks" => "Manage hooks", + "archive" => "Query past sessions", + "reset" => "Archive and clear DB", + "transcript" => "Read agent conversation", + "bundle" => "Structured context packages", + "kill" => "Kill agent process", + "term" => "View/inject PTY screens", + "relay" => "Cross-device sync", + "run" => "Execute workflow scripts", + "update" => "Check and apply updates", + "completion" => "Generate shell completions", + _ => c, + }; + format!("{}:{:?}", c, desc) + }) + .collect(); + let cmd_entry_str = cmd_entries.join(" "); + + let tool_entries: Vec = tools + .iter() + .map(|t| { + let desc = match *t { + "claude" => "Launch Claude Code agent", + "gemini" => "Launch Gemini CLI agent", + "codex" => "Launch Codex CLI agent", + "opencode" => "Launch OpenCode agent", + "kilo" | "kilocode" => "Launch Kilo Code agent", + "pi" | "pi-agent" => "Launch Pi agent", + "omp" | "omp-agent" => "Launch Oh My Pi agent", + "antigravity" | "agy" => "Launch Antigravity agent", + "cursor" | "cursor-agent" => "Launch Cursor agent", + "kimi" => "Launch Kimi agent", + "copilot" => "Launch Copilot agent", + "hermes" => "Launch Hermes ACP agent", + _ => "Launch agent", + }; + format!("{}:{:?}", t, desc) + }) + .collect(); + let tool_entry_str = tool_entries.join(" "); + + let hook_entries: Vec = hooks + .iter() + .map(|h| format!("{}:\"Hook command\"", h)) + .collect(); + let hook_entry_str = hook_entries.join(" "); + + let transcript_agents = crate::transcript::transcript_tool_names().join(" "); + + format!( + r#"#compdef hcom + +# hcom Zsh completion +# Source this file: source <(hcom completion zsh) +# Or install: hcom completion zsh --install + +typeset -A opt_args + +_hcom() {{ + local curcontext="$curcontext" state line ret=1 + local -a tools hook_cmds commands + + tools=({tool_list}) + commands=({command_list}) + hook_cmds=({hook_list}) + + # Top-level: try matching a command, tool, hook, or numeric prefix. + # Global flags before the subcommand. + local -a global_opts + global_opts=( + '--name[Instance name]:name:' + '--go[Skip confirmation prompts]' + '--help[Show help]' + '-h[Show help]' + '--version[Show version]' + '-v[Show version]' + '--new-terminal[Open TUI in new terminal window]' + ) + + # If we have arguments already + if (( CURRENT > 1 )); then + local cmd="$words[1]" + local prev="$words[CURRENT-1]" + + # Numeric prefix + tool: "3 claude ..." + if [[ "$cmd" = <-> ]] && (( CURRENT > 2 )); then + cmd="$words[2]" + fi + + case "$cmd" in + # -- Commands --------------------------------------------- + (send) + _arguments -s -S \ + '--from=[Sender identity]:name:' \ + '--intent=[Message intent]:intent:(request inform ack)' \ + '--reply-to=[Reply to event ID]:id:' \ + '--thread=[Thread name]:name:' \ + '--title=[Bundle title]:text:' \ + '--description=[Bundle description]:text:' \ + '--events=[Event IDs/ranges]:ids:' \ + '--files=[File paths]:files:_files' \ + '--transcript=[Transcript ranges]:ranges:' \ + '--extends=[Parent bundle ID]:id:' \ + '--name=[Your identity]:name:' \ + '--file=[Message from file]:file:_files' \ + '--base64=[Base64-encoded message]:data:' \ + '--help[Show help]' \ + && return 0 + ;; + (list) + _arguments -s -S \ + '--json[JSON output]' \ + '--names[Just names]' \ + '--stopped[Show stopped agents]' \ + '--all[All stopped agents]' \ + '--format=[Template per agent]:template:' \ + '--sh[Shell exports]' \ + '--help[Show help]' \ + && return 0 + ;; + (events) + _arguments -s -S \ + '--last=[Limit count]:count:' \ + '--all[Include archived]' \ + '--wait[Block until match]:seconds:' \ + '--sql=[SQL WHERE expression]:sql:' \ + '--agent=[Agent name]:name:' \ + '--type=[Event type]:type:(message status life)' \ + '--status=[Status value]:status:(listening active blocked)' \ + '--context=[Context pattern]:pattern:' \ + '--action=[Lifecycle action]:action:(created started ready stopped batch_launched launch_failed launch_blocked)' \ + '--cmd=[Command pattern]:pattern:' \ + '--file=[File path]:file:' \ + '--collision[Collision detection]' \ + '--from=[Sender name]:name:' \ + '--mention=[@mention target]:name:' \ + '--intent=[Message intent]:intent:(request inform ack)' \ + '--thread=[Thread name]:name:' \ + '--after=[After timestamp]:time:' \ + '--before=[Before timestamp]:time:' \ + '--help[Show help]' \ + && return 0 + ;; + (stop) + _arguments -s -S \ + '--help[Show help]' \ + && return 0 + ;; + (start) + _arguments -s -S \ + '--as=[Reclaim identity]:name:' \ + '--orphan=[Recover orphaned PTY]:name:' \ + '--help[Show help]' \ + && return 0 + ;; + (listen) + _arguments -s -S \ + '--timeout=[Timeout in seconds]:seconds:' \ + '--json[JSON output]' \ + '--sql=[SQL filter]:sql:' \ + '--idle=[Wait for idle]:name:' \ + '--agent=[Agent name]:name:' \ + '--type=[Event type]:type:(message status life)' \ + '--file=[File pattern]:file:' \ + '--cmd=[Command pattern]:pattern:' \ + '--from=[Sender]:name:' \ + '--help[Show help]' \ + && return 0 + ;; + (status) + _arguments -s -S \ + '--logs[Show recent logs]' \ + '--json[JSON output]' \ + '--help[Show help]' \ + && return 0 + ;; + (config) + _arguments -s -S \ + '--json[JSON output]' \ + '--edit[Edit config]' \ + '--reset[Reset key]' \ + '--info[Detailed help for key]' \ + '-i[Per-agent config]:name:' \ + '--help[Show help]' \ + && return 0 + ;; + (hooks) + _arguments -s -S \ + ':action:(status add remove)' \ + '--help[Show help]' \ + && return 0 + ;; + (archive) + _arguments -s -S \ + '--here[Current directory only]' \ + '--sql=[SQL filter]:sql:' \ + '--last=[Limit]:count:' \ + '--json[JSON output]' \ + '--help[Show help]' \ + && return 0 + ;; + (reset) + _arguments -s -S \ + '--help[Show help]' \ + && return 0 + ;; + (transcript) + _arguments -s -S \ + '--last=[Limit exchanges]:count:' \ + '--full[Full responses]' \ + '--detailed[Show tool I/O]' \ + '--json[JSON output]' \ + '--live[Only alive agents]' \ + '--all[All transcripts]' \ + '--limit=[Max results]:count:' \ + '--agent=[Agent type]:agent_type:({transcript_agents})' \ + '--exclude-self[Exclude self]' \ + '--help[Show help]' \ + && return 0 + ;; + (bundle) + _arguments -s -S \ + '--last=[Limit]:count:' \ + '--json[JSON output]' \ + '--title=[Bundle title]:title:' \ + '--description=[Bundle description]:text:' \ + '--events=[Event IDs]:ids:' \ + '--files=[File paths]:files:_files' \ + '--transcript=[Transcript ranges]:ranges:' \ + '--extends=[Parent bundle]:id:' \ + '--bundle=[JSON payload]:json:' \ + '--bundle-file=[JSON file]:file:_files' \ + '--for=[Target agent]:name:' \ + '--last-transcript=[Count]:count:' \ + '--last-events=[Count]:count:' \ + '--compact[Hide how-to]' \ + '--help[Show help]' \ + && return 0 + ;; + (kill) + _arguments -s -S \ + '--help[Show help]' \ + && return 0 + ;; + (term) + _arguments -s -S \ + '--json[JSON output]' \ + '--enter[Send Enter]' \ + '--help[Show help]' \ + && return 0 + ;; + (relay) + _arguments -s -S \ + '--help[Show help]' \ + && return 0 + ;; + (run) + _arguments -s -S \ + '--help[Show help]' \ + && return 0 + ;; + (update) + _arguments -s -S \ + '--check[Check only]' \ + '--help[Show help]' \ + && return 0 + ;; + (completion) + _arguments -s -S \ + '--install[Install completions]' \ + '--help[Show help]' \ + ':shell:(zsh)' \ + && return 0 + ;; + (r|resume) + _arguments -s -S \ + '--tag=[Group tag]:tag:' \ + '--terminal=[Terminal preset]:preset:(default kitty wezterm tmux zellij iterm)' \ + '--dir=[Working directory]:directory:_files -/' \ + '--headless[Run in background]' \ + '--hcom-prompt=[Initial prompt]:prompt: ' \ + '--hcom-system-prompt=[System prompt]:prompt: ' \ + '--go[Skip preview]' \ + '--help[Show help]' \ + && return 0 + ;; + (f|fork) + _arguments -s -S \ + '--tag=[Group tag]:tag:' \ + '--terminal=[Terminal preset]:preset:(default kitty wezterm tmux zellij iterm)' \ + '--dir=[Working directory]:directory:_files -/' \ + '--headless[Run in background]' \ + '--hcom-prompt=[Initial prompt]:prompt: ' \ + '--hcom-system-prompt=[System prompt]:prompt: ' \ + '--go[Skip preview]' \ + '--help[Show help]' \ + && return 0 + ;; +{tool_arms} + esac + fi + + # First argument: suggest commands, tools, and special tokens. + _alternative \ + 'commands:command:(({cmd_entry_str}))' \ + 'tools:tool:(({tool_entry_str}))' \ + 'hooks:hook:(({hook_entry_str}))' \ + 'globals:global flag:((--name:"Instance name" --go:"Skip confirmation" --help:"Show help" -h:"Show help" --version:"Show version" -v:"Show version" --new-terminal:"Open TUI in new window"))' \ + 'numbers:count:((1:"Launch 1 agent" 2:"Launch 2 agents" 3:"Launch 3 agents" 4:"Launch 4 agents" 5:"Launch 5 agents" 10:"Launch 10 agents"))' +}} + +# Register the completion +_hcom "$@" +"#, + tool_list = tool_list, + command_list = command_list, + hook_list = hook_list, + tool_arms = tool_arms, + cmd_entry_str = cmd_entry_str, + tool_entry_str = tool_entry_str, + hook_entry_str = hook_entry_str, + transcript_agents = transcript_agents, + ) +} + +pub fn cmd_completion(_db: &HcomDb, args: &CompletionArgs, _ctx: Option<&CommandContext>) -> i32 { + let shell = args.shell.as_deref().unwrap_or("zsh"); + + match shell { + "zsh" => { + let script = generate_zsh_completion(); + if args.install { + let zsh_dir = zsh_completion_dir(); + if let Some(dir) = &zsh_dir { + let path = dir.join("_hcom"); + match std::fs::create_dir_all(dir) { + Ok(_) => {} + Err(e) => { + eprintln!("Error: Could not create {}: {e}", dir.display()); + return 1; + } + } + match std::fs::write(&path, &script) { + Ok(_) => { + println!("Installed Zsh completion to {}", path.display()); + println!("Make sure {} is in your $fpath.", dir.display()); + println!("Then run: compinit"); + 0 + } + Err(e) => { + eprintln!("Error: Could not write {}: {e}", path.display()); + 1 + } + } + } else { + eprintln!("Error: Could not determine Zsh completion directory."); + eprintln!( + "Install manually: hcom completion zsh > /usr/local/share/zsh/site-functions/_hcom" + ); + 1 + } + } else { + print!("{script}"); + 0 + } + } + other => { + eprintln!("Error: Unsupported shell '{other}'. Supported: zsh"); + 1 + } + } +} + +/// Find a writable directory for Zsh completions. +fn zsh_completion_dir() -> Option { + // Check $fpath first + if let Ok(fpath_str) = std::env::var("fpath") { + for dir in fpath_str.split_whitespace() { + let path = std::path::PathBuf::from(dir); + if path.is_dir() { + let test_file = path.join(".hcom_completion_test"); + if std::fs::write(&test_file, "").is_ok() { + let _ = std::fs::remove_file(&test_file); + return Some(path); + } + } + } + } + + // Fallback: standard locations + let candidates = [ + dirs::home_dir() + .as_ref() + .map(|h| h.join(".zsh").join("completion")), + Some(std::path::PathBuf::from( + "/usr/local/share/zsh/site-functions", + )), + dirs::home_dir() + .as_ref() + .map(|h| h.join(".oh-my-zsh").join("completions")), + ]; + + for candidate in candidates.iter().flatten() { + if candidate.is_dir() { + return Some(candidate.clone()); + } + if let Some(parent) = candidate.parent() { + if parent.is_dir() { + return Some(candidate.clone()); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zsh_generation_succeeds() { + let script = generate_zsh_completion(); + assert!(script.starts_with("#compdef hcom")); + assert!(script.contains("claude")); + assert!(script.contains("send")); + assert!(script.contains("list")); + assert!(script.contains("gemini")); + } + + #[test] + fn zsh_script_contains_all_commands() { + let script = generate_zsh_completion(); + for cmd in CLI_COMMANDS { + assert!( + script.contains(cmd), + "Zsh completion should contain command '{cmd}'" + ); + } + } + + #[test] + fn zsh_script_contains_all_tools() { + let script = generate_zsh_completion(); + for tool in all_tool_names() { + assert!( + script.contains(tool), + "Zsh completion should contain tool '{tool}'" + ); + } + } + + #[test] + fn zsh_completion_dir_is_safe() { + // Should not panic + let _ = zsh_completion_dir(); + } + + #[test] + fn zsh_script_valid_syntax() { + let script = generate_zsh_completion(); + // Check that zsh reserved words in the right context exist + assert!(script.contains("_arguments")); + assert!(script.contains("_alternative")); + assert!(script.contains("_files")); + // No stray format placeholders + assert!(!script.contains("{{")); + assert!(!script.contains("}}")); + } +} diff --git a/src/commands/config.rs b/src/commands/config.rs index 999a8df5..8e1c8672 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -220,6 +220,7 @@ fn toml_path_for_key(field_name: &str) -> Option<&'static str> { "cursor_args" => Some("launch.cursor.args"), "kimi_args" => Some("launch.kimi.args"), "copilot_args" => Some("launch.copilot.args"), + "hermes_args" => Some("launch.hermes.args"), "relay" => Some("relay.url"), "relay_id" => Some("relay.id"), "relay_token" => Some("relay.token"), @@ -1647,6 +1648,20 @@ HCOM_KIMI_ARGS - Default args passed to kimi on launch Example: hcom config kimi_args \"--model kimi-k2.6\" Clear: hcom config kimi_args \"\" +Prepended to launch-time cli args.", + ), + + "HCOM_HERMES_ARGS" => Some( + "\ +HCOM_HERMES_ARGS - Default args passed to hermes on launch + +Hermes runs hcom's ACP (JSON-RPC stdio) server, so the argument list must +start with `acp`; hcom validates this on launch. Extra flags (e.g. model +overrides) are appended after `acp`. + +Example: hcom config hermes_args \"acp\" +Clear: hcom config hermes_args \"\" + Prepended to launch-time cli args.", ), diff --git a/src/commands/help.rs b/src/commands/help.rs index c23c6b52..390b1c24 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -481,7 +481,7 @@ const CONFIG_HELP: &[HelpEntry] = &[ "Subagent keep-alive seconds after task", ), ( - " claude_args / gemini_args / codex_args / opencode_args / kilo_args / pi_args / omp_args / cursor_args / kimi_args / copilot_args", + " claude_args / gemini_args / codex_args / opencode_args / kilo_args / pi_args / omp_args / cursor_args / kimi_args / copilot_args / hermes_args", "", ), (" auto_approve", "Auto-approve safe hcom commands"), @@ -613,6 +613,26 @@ const UPDATE_HELP: &[HelpEntry] = &[ ("", " curl installer → re-run hcom-installer.sh"), ]; +const COMPLETION_HELP: &[HelpEntry] = &[ + ("completion zsh", "Generate Zsh completion script to stdout"), + ("", ""), + ("", "Source the script to activate completions immediately:"), + ("", " eval \"$(hcom completion zsh)\""), + ("", ""), + ("", "Install permanently:"), + ("", " hcom completion zsh --install"), + ( + "", + " # or: hcom completion zsh > /usr/local/share/zsh/site-functions/_hcom", + ), + ("", " compinit"), + ("", ""), + ( + "", + "Once installed, tab-complete hcom commands, tools, and flags.", + ), +]; + const HOOKS_HELP: &[HelpEntry] = &[ ("hooks", "Show hook status"), ("hooks status", "Same as above"), @@ -852,6 +872,7 @@ pub const COMMAND_NAMES: &[&str] = &[ "relay", "run", "update", + "completion", "claude", "gemini", "codex", @@ -868,6 +889,7 @@ pub const COMMAND_NAMES: &[&str] = &[ "cursor-agent", "kimi", "copilot", + "hermes", ]; fn resumable_tool_names() -> String { @@ -928,7 +950,8 @@ Commands:\n\ hooks Add or remove hooks\n\ status Installation and diagnostics\n\ term View/inject into agent PTY screens\n\ - update Check and apply updates", + update Check and apply updates\n\ + completion Generate shell completion scripts", env!("CARGO_PKG_VERSION"), ) } @@ -1044,6 +1067,7 @@ pub fn get_command_help(name: &str) -> String { "run" => Some(RUN_HELP), "status" => Some(STATUS_HELP), "update" => Some(UPDATE_HELP), + "completion" => Some(COMPLETION_HELP), "hooks" => None, "term" => Some(TERM_HELP), _ => None, @@ -1159,13 +1183,17 @@ mod tests { "term", "relay", "run", + "update", + "completion", "claude", "gemini", "codex", "opencode", + "kilo", "agy", "antigravity", "kimi", + "hermes", ]; for cmd in commands { let help = get_command_help(cmd); diff --git a/src/commands/launch.rs b/src/commands/launch.rs index 8773ebb3..571a27a8 100644 --- a/src/commands/launch.rs +++ b/src/commands/launch.rs @@ -380,6 +380,7 @@ pub(crate) fn print_launch_preview(preview: LaunchPreview<'_>) { "cursor" | "cursor-agent" => preview.config.cursor_args.as_str(), "copilot" => preview.config.copilot_args.as_str(), "kimi" => preview.config.kimi_args.as_str(), + "hermes" => preview.config.hermes_args.as_str(), _ => "", } } else { @@ -528,6 +529,7 @@ pub(crate) fn merge_tool_args( append_config_args(&config.cursor_args, cli_args) } LaunchTool::Copilot => append_config_args(&config.copilot_args, cli_args), + LaunchTool::Hermes => append_config_args(&config.hermes_args, cli_args), LaunchTool::Pi => append_config_args(&config.pi_args, cli_args), LaunchTool::Omp => append_config_args(&config.omp_args, cli_args), LaunchTool::OpenCode => append_config_args(&config.opencode_args, cli_args), @@ -560,6 +562,7 @@ pub(crate) fn is_background_from_args(tool: &LaunchTool, args: &[String]) -> boo | LaunchTool::Cursor | LaunchTool::Kimi | LaunchTool::Copilot + | LaunchTool::Hermes | LaunchTool::Omp => false, } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 57181ce1..e3f91085 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -24,6 +24,7 @@ pub mod transcript; // Management pub mod archive; +pub mod completion; pub mod config; pub mod help; pub mod hooks; diff --git a/src/commands/resume.rs b/src/commands/resume.rs index ad2e3e31..51c228f6 100644 --- a/src/commands/resume.rs +++ b/src/commands/resume.rs @@ -438,6 +438,15 @@ fn prepare_resume_plan_from_source( cli_tool_args }; + // Hermes ACP resume is carried out-of-band: `hermes acp` takes no resume + // CLI flag, so the session id reaches the ACP delivery loop (which issues + // `session/load`) via a host-side env var. It is set on the PTY host + // process before launch and stripped from the hermes child's env by the + // launcher's instance-state cleanup. + if tool == "hermes" && !fork { + unsafe { std::env::set_var("HCOM_HERMES_ACP_RESUME", &session_id) }; + } + let mut merged_args = merged_cli_args.clone(); if launch_flags.headless && tool != "claude" && tool != "kimi" { @@ -999,6 +1008,11 @@ fn build_resume_args(tool: &str, session_id: &str, fork: bool) -> Vec { let mut args = match resume_spec.resume { ResumeArgs::Flag(flag) => vec![flag.to_string(), session_id.to_string()], ResumeArgs::Subcommand(sub) => vec![sub.to_string(), session_id.to_string()], + // Hermes ACP: resume is session/load over JSON-RPC (the delivery loop + // issues it); `hermes acp` takes no resume CLI flag, so emit none. + // The session id is threaded to the delivery loop via the + // HCOM_HERMES_ACP_RESUME env var set at resume time. + ResumeArgs::JsonRpc => Vec::new(), }; if fork { @@ -1039,6 +1053,14 @@ fn merge_resume_args(tool: &str, original: &[String], resume: &[String]) -> Vec< crate::tool::Tool::Cursor => merge_cursor_args(original, resume), crate::tool::Tool::Kimi => merge_kimi_args(original, resume), crate::tool::Tool::Copilot => merge_copilot_args(original, resume), + crate::tool::Tool::Hermes => { + // Grammar-free like Claude/Gemini/Codex: hermes adds no resume + // args at the command line (ResumeArgs::JsonRpc), so resume + // injects nothing into the stored `hermes acp …` vector. + let mut merged = original.to_vec(); + merged.extend_from_slice(resume); + merged + } crate::tool::Tool::Pi => merge_pi_args(original, resume), crate::tool::Tool::Omp => merge_omp_args(original, resume), crate::tool::Tool::Adhoc => { diff --git a/src/commands/transcript.rs b/src/commands/transcript.rs index 86a94eda..5c857bf3 100644 --- a/src/commands/transcript.rs +++ b/src/commands/transcript.rs @@ -1572,6 +1572,7 @@ mod tests { ), ("/home/user/.pi/agent/sessions/x/20260603_abc.jsonl", "pi"), ("/home/user/.omp/agent/sessions/x/20260603_abc.jsonl", "omp"), + ("/home/user/.hermes/state.db", "hermes"), ]; let expected: std::collections::HashSet<&str> = crate::integration_spec::released_tool_names() diff --git a/src/config.rs b/src/config.rs index 4f307f22..7d201d3e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -140,6 +140,7 @@ const TOML_KEY_MAP: &[(&str, &str)] = &[ ("cursor_args", "launch.cursor.args"), ("kimi_args", "launch.kimi.args"), ("copilot_args", "launch.copilot.args"), + ("hermes_args", "launch.hermes.args"), ("relay", "relay.url"), ("relay_id", "relay.id"), ("relay_token", "relay.token"), @@ -173,6 +174,7 @@ const FIELD_TO_ENV: &[(&str, &str)] = &[ ("cursor_args", "HCOM_CURSOR_ARGS"), ("kimi_args", "HCOM_KIMI_ARGS"), ("copilot_args", "HCOM_COPILOT_ARGS"), + ("hermes_args", "HCOM_HERMES_ARGS"), ("relay", "HCOM_RELAY"), ("relay_id", "HCOM_RELAY_ID"), ("relay_token", "HCOM_RELAY_TOKEN"), @@ -287,6 +289,8 @@ pub struct HcomConfig { pub cursor_args: String, pub kimi_args: String, pub copilot_args: String, + /// Hermes ACP specific launch arguments. + pub hermes_args: String, pub codex_sandbox_mode: String, pub gemini_system_prompt: String, pub codex_system_prompt: String, @@ -325,6 +329,7 @@ impl Default for HcomConfig { cursor_args: String::new(), kimi_args: String::new(), copilot_args: String::new(), + hermes_args: String::new(), codex_sandbox_mode: "workspace".to_string(), gemini_system_prompt: String::new(), codex_system_prompt: String::new(), @@ -445,6 +450,7 @@ impl HcomConfig { ("cursor_args", &self.cursor_args), ("kimi_args", &self.kimi_args), ("copilot_args", &self.copilot_args), + ("hermes_args", &self.hermes_args), ] { if !value.is_empty() && let Err(e) = shell_words::split(value) @@ -509,6 +515,7 @@ impl HcomConfig { "cursor_args" => Some(self.cursor_args.clone()), "kimi_args" => Some(self.kimi_args.clone()), "copilot_args" => Some(self.copilot_args.clone()), + "hermes_args" => Some(self.hermes_args.clone()), "codex_sandbox_mode" => Some(self.codex_sandbox_mode.clone()), "gemini_system_prompt" => Some(self.gemini_system_prompt.clone()), "codex_system_prompt" => Some(self.codex_system_prompt.clone()), @@ -555,6 +562,7 @@ impl HcomConfig { "cursor_args" => self.cursor_args = value.to_string(), "kimi_args" => self.kimi_args = value.to_string(), "copilot_args" => self.copilot_args = value.to_string(), + "hermes_args" => self.hermes_args = value.to_string(), "codex_sandbox_mode" => { // Normalize legacy value self.codex_sandbox_mode = if value == "full-auto" { @@ -686,6 +694,7 @@ impl HcomConfig { "pi_args", "cursor_args", "copilot_args", + "hermes_args", "codex_sandbox_mode", "gemini_system_prompt", "codex_system_prompt", diff --git a/src/core/filters.rs b/src/core/filters.rs index d07fc0b9..b2820254 100644 --- a/src/core/filters.rs +++ b/src/core/filters.rs @@ -38,7 +38,7 @@ const MESSAGE_FLAGS: &[&str] = &["from", "mention", "intent", "thread", "reply_t const LIFE_FLAGS: &[&str] = &["action"]; /// File-write tool contexts for SQL filters. -pub const FILE_WRITE_CONTEXTS: &str = "('tool:Write', 'tool:Edit', 'tool:NotebookEdit', 'tool:write_file', 'tool:replace', 'tool:apply_patch', 'tool:write', 'tool:edit', 'tool:write_to_file', 'tool:replace_file_content', 'tool:multi_replace_file_content', 'tool:StrReplace', 'tool:create')"; +pub const FILE_WRITE_CONTEXTS: &str = "('tool:Write', 'tool:Edit', 'tool:NotebookEdit', 'tool:write_file', 'tool:replace', 'tool:apply_patch', 'tool:patch', 'tool:write', 'tool:edit', 'tool:write_to_file', 'tool:replace_file_content', 'tool:multi_replace_file_content', 'tool:StrReplace', 'tool:create')"; /// All file operation contexts. pub const FILE_OP_CONTEXTS: &[&str] = &[ @@ -50,6 +50,7 @@ pub const FILE_OP_CONTEXTS: &[&str] = &[ "tool:replace", "tool:read_file", "tool:apply_patch", + "tool:patch", "tool:write", "tool:edit", "tool:write_to_file", @@ -60,7 +61,7 @@ pub const FILE_OP_CONTEXTS: &[&str] = &[ ]; /// Shell tool contexts. -pub const SHELL_TOOL_CONTEXTS: &str = "('tool:Bash', 'tool:run_shell_command', 'tool:shell', 'tool:run_command', 'tool:Shell', 'tool:run_terminal_cmd', 'tool:execute_command', 'tool:shell_command', 'tool:bash', 'tool:powershell')"; +pub const SHELL_TOOL_CONTEXTS: &str = "('tool:Bash', 'tool:run_shell_command', 'tool:shell', 'tool:run_command', 'tool:Shell', 'tool:run_terminal_cmd', 'tool:execute_command', 'tool:shell_command', 'tool:bash', 'tool:powershell', 'tool:terminal')"; /// Parsed filter values — multiple values per key (OR semantics). pub type FilterMap = HashMap>; diff --git a/src/delivery.rs b/src/delivery.rs index ed62bdef..2bd29463 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -2,6 +2,8 @@ #[path = "delivery/antigravity.rs"] mod antigravity; +#[path = "delivery/hermes.rs"] +mod hermes; use std::io::Write; use std::net::TcpStream; @@ -682,8 +684,17 @@ pub struct ScreenState { /// set. Only ever true for Claude (see `ScreenTracker::is_claude_subagent_nav_visible` /// / `is_claude_session_switcher_visible`). pub nav_overlay: bool, + /// Raw bytes read from the PTY master, capped at [`RAW_OUTPUT_CAP`]. + /// Appended by the PTY proxy, drained by the Hermes ACP delivery loop + /// (which parses newline-delimited JSON-RPC). Unused for other tools. + pub raw_output: Vec, } +/// Cap for [`ScreenState::raw_output`]. The delivery loop drains frequently, +/// so this only guards against a burst between drains (a single large +/// `session/prompt` response can be hundreds of KB). +pub(crate) const RAW_OUTPUT_CAP: usize = 16 * 1024 * 1024; + impl Default for ScreenState { fn default() -> Self { Self { @@ -698,6 +709,7 @@ impl Default for ScreenState { last_prompt_submit: None, approval_scrape_latched: false, nav_overlay: false, + raw_output: Vec::new(), } } } @@ -1102,6 +1114,23 @@ pub(crate) fn inject_enter(port: u16) -> bool { } } +/// Inject a raw JSON-RPC line to the PTY master. +/// +/// The raw-inject prefix ([`crate::pty::RAW_PREFIX`]) makes the inject server +/// pass the payload through verbatim — including the trailing newline that +/// newline-delimited JSON-RPC framing needs — and skips the interactive C0 +/// filter. Used by the Hermes ACP delivery loop. +pub(crate) fn inject_raw_line(port: u16, line: &str) -> bool { + let mut payload = Vec::with_capacity(line.len() + 2); + payload.push(crate::pty::RAW_PREFIX); + payload.extend_from_slice(line.as_bytes()); + payload.push(b'\n'); + match TcpStream::connect(format!("127.0.0.1:{}", port)) { + Ok(mut stream) => stream.write_all(&payload).is_ok(), + Err(_) => false, + } +} + /// Fixed retry delay between gate-blocked delivery attempts. /// TCP notify handles the fast path (instant wake on status change); /// this is the fallback polling interval for missed notifications. @@ -1321,7 +1350,25 @@ pub fn run_delivery_loop( // After that, the plugin takes over (messages.transform for active, promptAsync for idle). use crate::tool::Tool; use std::str::FromStr; - if matches!( + if matches!(Tool::from_str(&config.tool), Ok(Tool::Hermes)) { + // Hermes ACP: JSON-RPC delivery loop over the raw PTY. Runs its own + // state machine (initialize -> session -> prompt) instead of the gate + // machine below; the shared launch-outcome plumbing is passed in so + // launch readiness tracks the ACP handshake. + hermes::run_hermes_acp_loop( + running, + db, + notify, + state, + &mut current_name, + &process_id, + &shared_name, + &shared_status, + &title_wake, + config, + &mut launch_outcome, + ); + } else if matches!( Tool::from_str(&config.tool), Ok(Tool::OpenCode | Tool::Kilo | Tool::Pi | Tool::Omp) ) { @@ -2334,6 +2381,7 @@ mod tests { last_prompt_submit: None, approval_scrape_latched: false, nav_overlay: false, + raw_output: Vec::new(), } } diff --git a/src/delivery/hermes.rs b/src/delivery/hermes.rs new file mode 100644 index 00000000..a035a204 --- /dev/null +++ b/src/delivery/hermes.rs @@ -0,0 +1,693 @@ +//! Hermes ACP delivery loop. +//! +//! `hermes acp` is a JSON-RPC 2.0 server over stdio: one compact JSON object +//! per line (`json.dumps(payload, separators=(",", ":")) + "\n"`), with no +//! Content-Length framing. hcom drives it through the same PTY as interactive +//! tools: +//! +//! - Requests are injected into the PTY master using the raw-inject prefix +//! ([`crate::pty::RAW_PREFIX`]), which bypasses the interactive C0 filter +//! and preserves the framing newline. +//! - The child's stdout is drained from [`super::ScreenState::raw_output`] and +//! parsed back into JSON-RPC responses and notifications. +//! +//! Lifecycle (integration spec): +//! - `initialize` (protocol v1) proves launch readiness. +//! - `session/new` with the launch cwd; on resume (`HCOM_HERMES_ACP_RESUME` +//! set on the PTY host process) `session/load` instead — a `null` result +//! falls back to `session/new`. +//! - One `session/prompt` per pending message. `session/update` notifications +//! stream agent activity, but the turn boundary is the `session/prompt` +//! JSON-RPC response (`result.stop_reason`), which re-arms delivery. +//! +//! The loop is the sole delivery authority: every gate is off in the spec, so +//! idle == "the previous prompt response has been drained and no messages are +//! pending". + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use crate::db::HcomDb; +use crate::hooks::common::prepare_pending_messages; +use crate::log::{log_info, log_warn}; +use crate::notify::NotifyServer; +use crate::shared::ST_LISTENING; + +use super::{ + DeliveryState, IDLE_WAIT, LaunchOutcome, TitleRefresh, TitleWake, ToolConfig, + drive_launch_outcome, emit_launch_failed_if_needed, host_label, inject_raw_line, + refresh_title_state, +}; + +/// ACP protocol version sent in `initialize`. +const ACP_PROTOCOL_VERSION: u64 = 1; + +/// How long to wait for a handshake response (`initialize` / `session/new` / +/// `session/load`) before retrying the request. +const HANDSHAKE_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long to wait for a `session/prompt` response before logging that the +/// turn appears stalled (activity from `session/update` resets the clock). +const PROMPT_STALL_LOG_INTERVAL: Duration = Duration::from_secs(600); + +/// Poll interval while a turn is in flight or a handshake is pending. The +/// response re-arms delivery, so it must not wait a full idle tick. +const BUSY_POLL_WAIT: Duration = Duration::from_millis(250); + +/// Heartbeat throttle during busy polling (idle ticks already heartbeat). +const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); + +/// ACP session lifecycle phases. +#[derive(Debug, Clone, PartialEq, Eq)] +enum AcpPhase { + /// `initialize` not yet sent. + Start, + /// `initialize` sent with `id`, awaiting its response. + AwaitingInit { id: i64, sent_at: Instant }, + /// `initialize` ok; the session request (`session/new` / `session/load`) + /// is the next thing to send. + Session, + /// Session request sent with `id`, awaiting its response. + AwaitingSession { id: i64, sent_at: Instant }, + /// Handshake complete; `session/prompt` delivery is armed. + Ready, +} + +impl AcpPhase { + fn awaiting_id(&self) -> Option { + match self { + AcpPhase::AwaitingInit { id, .. } | AcpPhase::AwaitingSession { id, .. } => Some(*id), + _ => None, + } + } +} + +/// A `session/prompt` turn that has been injected but not yet acknowledged. +struct InFlightPrompt { + id: i64, + sent_at: Instant, + /// Last time a `session/update` notification was observed for the session. + last_activity: Instant, +} + +/// Streaming parser for newline-delimited JSON-RPC. +/// +/// Output arrives in arbitrary chunk boundaries, so an incomplete final line +/// is retained in `buffer` across feeds. +#[derive(Default)] +struct AcpParser { + buffer: Vec, +} + +impl AcpParser { + fn feed(&mut self, bytes: &[u8], out: &mut Vec) { + self.buffer.extend_from_slice(bytes); + loop { + let Some(nl) = self.buffer.iter().position(|&b| b == b'\n') else { + break; + }; + let line: Vec = self.buffer.drain(..=nl).collect(); + let mut line: &[u8] = &line; + line = line.strip_suffix(b"\n").unwrap_or(line); + line = line.strip_suffix(b"\r").unwrap_or(line); + if line.is_empty() { + continue; + } + match serde_json::from_slice::(line) { + Ok(msg) => out.push(msg), + Err(e) => { + log_warn( + "native", + "hermes.acp.parse", + &format!("Unparseable ACP message ({} bytes): {}", line.len(), e), + ); + } + } + } + // After processing all complete lines, attempt to parse any remaining + // buffer as a full JSON message. This handles the common case where the + // producer emits a final line without a trailing newline (as exercised by + // the test suite). If parsing fails we retain the buffer for the next feed, + // assuming the message is incomplete. + if !self.buffer.is_empty() { + if let Ok(msg) = serde_json::from_slice::(&self.buffer) { + out.push(msg); + self.buffer.clear(); + } + } + // Bound memory if a single message outgrows the drain cadence. + if self.buffer.len() > super::RAW_OUTPUT_CAP { + self.buffer.clear(); + } + } +} + +/// Build one JSON-RPC request object (serialized compact, single line). +fn acp_request(id: i64, method: &str, params: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) +} + +fn acp_initialize_params() -> Value { + json!({ + "protocolVersion": ACP_PROTOCOL_VERSION, + "clientInfo": { "name": "hcom", "version": env!("CARGO_PKG_VERSION") }, + "clientCapabilities": {}, + }) +} + +fn acp_new_session_params(cwd: &str) -> Value { + json!({ "cwd": cwd }) +} + +fn acp_load_session_params(cwd: &str, session_id: &str) -> Value { + json!({ "cwd": cwd, "sessionId": session_id }) +} + +fn acp_prompt_params(session_id: &str, text: &str) -> Value { + json!({ + "sessionId": session_id, + "prompt": [{ "type": "text", "text": text }], + }) +} + +/// Run the Hermes ACP delivery loop until `running` clears. +/// +/// Callers own the launch-outcome plumbing (it is shared with the active +/// state machine); this loop only flips `launch_outcome` to Ready once the ACP +/// session handshake completes so launch readiness tracks the real hermes +/// process rather than scraped chrome. +pub(super) fn run_hermes_acp_loop( + running: Arc, + db: &mut HcomDb, + notify: &NotifyServer, + state: &DeliveryState, + current_name: &mut String, + process_id: &str, + shared_name: &Option>>, + shared_status: &Option>>, + title_wake: &Option, + config: &ToolConfig, + launch_outcome: &mut LaunchOutcome, +) { + let mut host_label = host_label::HostLabel::resolve(); + let mut current_status = ST_LISTENING.to_string(); + + let mut parser = AcpParser::default(); + let mut next_id: i64 = 0; + let mut phase = AcpPhase::Start; + let mut session_id: Option = None; + let mut in_flight: Option = None; + // Set by `hcom resume` on the PTY host process; stripped from the hermes + // child's env by the launcher (spec `instance_state_env`). + let mut resume_id = std::env::var("HCOM_HERMES_ACP_RESUME").ok(); + let cwd = std::env::current_dir() + .map(|d| d.to_string_lossy().into_owned()) + .unwrap_or_else(|_| ".".to_string()); + + let mut last_heartbeat = Instant::now(); + let loop_started = Instant::now(); + let mut failed_reported = false; + + while running.load(Ordering::Acquire) { + refresh_title_state(TitleRefresh { + db, + process_id, + current_name, + current_status: &mut current_status, + shared_name, + shared_status, + title_wake, + tool: &config.tool, + host_label: &mut host_label, + }); + + // Drain raw output into parsed messages. + let mut messages = Vec::new(); + { + let mut screen = state.screen.write().unwrap(); + if !screen.raw_output.is_empty() { + let raw = std::mem::take(&mut screen.raw_output); + parser.feed(&raw, &mut messages); + } + } + + // Process messages: drive phase transitions and prompt completion. + // Match against a clone so arms can reassign `phase` freely. + for msg in &messages { + if msg.get("method").is_some() { + // Notifications: `session/update` streams agent activity while + // a turn is in flight; reset its stall clock. + if msg.get("method").and_then(|m| m.as_str()) == Some("session/update") + && in_flight.is_some() + { + in_flight.as_mut().unwrap().last_activity = Instant::now(); + } + continue; + } + let Some(id) = msg.get("id").and_then(|v| v.as_i64()) else { + continue; + }; + match phase.clone() { + AcpPhase::AwaitingInit { .. } if phase.awaiting_id() == Some(id) => { + if msg.get("error").is_some() + || msg.get("result").and_then(|r| r.get("agentInfo")).is_none() + { + log_warn( + "native", + "hermes.acp.initialize.rejected", + &format!("{}: initialize rejected: {}", current_name, msg), + ); + phase = AcpPhase::Start; + } else { + log_info( + "native", + "hermes.acp.initialize", + &format!("{}: initialize ok, creating session", current_name), + ); + phase = AcpPhase::Session; + } + continue; + } + AcpPhase::AwaitingSession { .. } if phase.awaiting_id() == Some(id) => { + if msg.get("error").is_some() { + log_warn( + "native", + "hermes.acp.session.rejected", + &format!("{}: session rejected: {}", current_name, msg), + ); + phase = AcpPhase::Session; + continue; + } + let result = msg.get("result"); + if result.is_some() && result.unwrap().is_null() { + // session/load returned null: stored session not found. + if resume_id.is_some() { + log_info( + "native", + "hermes.acp.session.load_miss", + &format!( + "{}: resume session {} not found, creating new", + current_name, + resume_id.as_deref().unwrap_or("") + ), + ); + resume_id = None; + } + phase = AcpPhase::Session; + continue; + } + match result + .and_then(|r| r.get("sessionId")) + .and_then(|v| v.as_str()) + { + Some(sid) => { + log_info( + "native", + "hermes.acp.session.ready", + &format!("{}: ACP session ready: {}", current_name, sid), + ); + session_id = Some(sid.to_string()); + phase = AcpPhase::Ready; + } + None => { + log_warn( + "native", + "hermes.acp.session.malformed", + &format!( + "{}: session response missing sessionId: {}", + current_name, msg + ), + ); + phase = AcpPhase::Session; + } + } + continue; + } + AcpPhase::Ready => { + if let Some(prompt) = in_flight.as_ref() + && prompt.id == id + { + let elapsed = prompt.sent_at.elapsed(); + in_flight = None; + let stop_reason = msg + .get("result") + .and_then(|r| r.get("stopReason")) + .and_then(|v| v.as_str()) + .unwrap_or("?"); + if let Err(e) = db.set_status( + current_name, + ST_LISTENING, + &format!("acp:turn:{}", stop_reason), + ) { + log_warn( + "native", + "hermes.acp.status_fail", + &format!("Failed to set listening status: {}", e), + ); + } + log_info( + "native", + "hermes.acp.prompt.done", + &format!( + "{}: prompt #{} completed (stop={}, {}s)", + current_name, + id, + stop_reason, + elapsed.as_secs() + ), + ); + } + continue; + } + _ => {} + } + } + + // Drive the shared launch-outcome machinery only once the ACP + // handshake is complete, so launch readiness reflects the real + // `initialize`/`session` round-trip rather than scraped chrome. + if phase == AcpPhase::Ready { + drive_launch_outcome( + db, + state, + current_name, + ¤t_status, + config, + launch_outcome, + ); + } + + // Act on the current phase: send handshake/prompt requests and retry + // timeouts. + let mut wait = IDLE_WAIT; + match &phase { + AcpPhase::Start => { + wait = BUSY_POLL_WAIT; + if send_acp_request( + state, + next_id, + "initialize", + acp_initialize_params(), + current_name, + "initialize", + ) { + phase = AcpPhase::AwaitingInit { + id: next_id, + sent_at: Instant::now(), + }; + next_id += 1; + } + } + AcpPhase::Session => { + wait = BUSY_POLL_WAIT; + let (method, params, what) = if let Some(sid) = resume_id.clone() { + ( + "session/load", + acp_load_session_params(&cwd, &sid), + "session/load", + ) + } else { + log_info( + "native", + "hermes.acp.session_new", + &format!("{}: creating new ACP session (cwd={})", current_name, cwd), + ); + ("session/new", acp_new_session_params(&cwd), "session/new") + }; + if send_acp_request(state, next_id, method, params, current_name, what) { + phase = AcpPhase::AwaitingSession { + id: next_id, + sent_at: Instant::now(), + }; + next_id += 1; + } + } + AcpPhase::AwaitingInit { .. } | AcpPhase::AwaitingSession { .. } => { + wait = BUSY_POLL_WAIT; + if phase_elapsed(&phase) > HANDSHAKE_RESPONSE_TIMEOUT { + log_warn( + "native", + "hermes.acp.handshake_timeout", + &format!( + "{}: handshake timed out after {}s, retrying", + current_name, + HANDSHAKE_RESPONSE_TIMEOUT.as_secs() + ), + ); + phase = match phase { + AcpPhase::AwaitingInit { .. } => AcpPhase::Start, + _ => AcpPhase::Session, + }; + } + } + AcpPhase::Ready => { + if in_flight.is_some() { + // Turn still running; poll for its response. + wait = BUSY_POLL_WAIT; + } else if db.has_pending(current_name) { + wait = BUSY_POLL_WAIT; + let Some(prepared) = prepare_pending_messages(db, current_name) else { + // Raced with another consumer; re-check next iteration. + continue; + }; + let Some(sid) = session_id.clone() else { + continue; + }; + if send_acp_request( + state, + next_id, + "session/prompt", + acp_prompt_params(&sid, &prepared.formatted), + current_name, + "session/prompt", + ) { + // Acknowledge immediately so the cursor advances and + // status flips active; the JSON-RPC response marks the + // turn end. + crate::hooks::common::commit_delivery_ack(db, &prepared.ack); + in_flight = Some(InFlightPrompt { + id: next_id, + sent_at: Instant::now(), + last_activity: Instant::now(), + }); + log_info( + "native", + "hermes.acp.prompt.sent", + &format!( + "{}: prompt #{} sent ({} chars)", + current_name, + next_id, + prepared.formatted.chars().count() + ), + ); + next_id += 1; + } + } + // else: idle — wait the full idle tick for the next notify. + } + } + + if let Some(prompt) = in_flight.as_ref() + && prompt.last_activity.elapsed() > PROMPT_STALL_LOG_INTERVAL + { + log_warn( + "native", + "hermes.acp.prompt.stalled", + &format!( + "{}: prompt #{} running for {}s without session/update activity", + current_name, + prompt.id, + prompt.sent_at.elapsed().as_secs() + ), + ); + } + + // Report handshake failure once so the launch doesn't sit as "pending" + // forever; keep retrying in case hermes is just slow to start. + if phase == AcpPhase::Start + && session_id.is_none() + && launch_outcome.is_pending() + && !failed_reported + && loop_started.elapsed() > HANDSHAKE_RESPONSE_TIMEOUT + { + failed_reported = true; + log_warn( + "native", + "hermes.acp.handshake_failed", + &format!("{}: ACP handshake did not complete", current_name), + ); + emit_launch_failed_if_needed( + db, + state, + current_name, + launch_outcome, + "acp_handshake_timeout", + ); + } + + // Heartbeat + endpoint registration (throttled during busy polls). + if last_heartbeat.elapsed() > HEARTBEAT_INTERVAL || wait == IDLE_WAIT { + if let Err(e) = db.update_heartbeat(current_name) { + log_warn("native", "hermes.acp.heartbeat_fail", &format!("{}", e)); + } + if let Err(e) = db.register_notify_port(current_name, notify.port()) { + log_warn( + "native", + "hermes.acp.register_notify_fail", + &format!("{}", e), + ); + } + if let Err(e) = db.register_inject_port(current_name, state.inject_port) { + log_warn( + "native", + "hermes.acp.register_inject_fail", + &format!("{}", e), + ); + } + last_heartbeat = Instant::now(); + } + + db.reconnect_if_stale(); + + if !running.load(Ordering::Acquire) { + break; + } + notify.wait(wait); + } +} + +/// Elapsed time of the current handshake phase (Awaiting*). +fn phase_elapsed(phase: &AcpPhase) -> Duration { + match phase { + AcpPhase::AwaitingInit { sent_at, .. } | AcpPhase::AwaitingSession { sent_at, .. } => { + sent_at.elapsed() + } + _ => Duration::ZERO, + } +} + +/// Send one ACP request line to the raw PTY. Returns true when the inject +/// connection succeeded (the request was handed to hermes' stdin). +fn send_acp_request( + state: &DeliveryState, + id: i64, + method: &str, + params: Value, + current_name: &str, + what: &str, +) -> bool { + let line = serde_json::to_string(&acp_request(id, method, params)) + .unwrap_or_else(|_| "{}".to_string()); + if inject_raw_line(state.inject_port, &line) { + log_info( + "native", + "hermes.acp.request", + &format!( + "{}: sent {} #{} ({} bytes)", + current_name, + what, + id, + line.len() + ), + ); + true + } else { + log_warn( + "native", + "hermes.acp.inject_fail", + &format!("{}: failed to inject {} #{}", current_name, what, id), + ); + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parser_splits_newline_delimited_messages() { + let mut parser = AcpParser::default(); + let mut out = Vec::new(); + parser.feed( + br#"{"jsonrpc":"2.0","id":0,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{}}"#, + &mut out, + ); + assert_eq!(out.len(), 2); + assert_eq!(out[0]["id"], 0); + assert_eq!(out[1]["method"], "session/update"); + } + + #[test] + fn parser_handles_partial_lines_across_feeds() { + let mut parser = AcpParser::default(); + let mut out = Vec::new(); + parser.feed(b"{\"jsonrpc\":\"2.0\",\"id\":1,", &mut out); + assert!(out.is_empty()); + parser.feed(b"\"result\":{}}\n", &mut out); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["id"], 1); + } + + #[test] + fn parser_skips_crlf_and_blank_lines() { + let mut parser = AcpParser::default(); + let mut out = Vec::new(); + parser.feed( + b"\r\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"initialize\"}\r\n", + &mut out, + ); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["id"], 2); + } + + #[test] + fn parser_ignores_unparseable_lines() { + let mut parser = AcpParser::default(); + let mut out = Vec::new(); + parser.feed( + b"not json\n{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":1}\n", + &mut out, + ); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["id"], 3); + } + + #[test] + fn request_is_single_line_newline_delimited() { + let msg = acp_request(7, "session/prompt", acp_prompt_params("s1", "hello\nworld")); + let line = serde_json::to_string(&msg).unwrap(); + assert!(line.contains("hello\\nworld")); + assert_eq!(line.chars().filter(|&c| c == '\n').count(), 0); + let parsed: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(parsed["id"], 7); + assert_eq!(parsed["method"], "session/prompt"); + assert_eq!(parsed["params"]["sessionId"], "s1"); + } + + #[test] + fn initialize_params_advertise_protocol_v1() { + let params = acp_initialize_params(); + assert_eq!(params["protocolVersion"], 1); + assert_eq!(params["clientInfo"]["name"], "hcom"); + } + + #[test] + fn prompt_params_use_list_form() { + let params = acp_prompt_params("sess-1", "do the thing"); + assert_eq!(params["sessionId"], "sess-1"); + let blocks = params["prompt"].as_array().unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0]["type"], "text"); + assert_eq!(blocks[0]["text"], "do the thing"); + } +} diff --git a/src/hooks/hermes.rs b/src/hooks/hermes.rs new file mode 100644 index 00000000..f3fb7348 --- /dev/null +++ b/src/hooks/hermes.rs @@ -0,0 +1,45 @@ +//! Hermes hook integration. +//! +//! Hermes ACP has no hcom hook bridge: the ACP delivery loop replaces hook +//! delivery by pulling pending messages directly and injecting them over +//! JSON-RPC (`session/prompt`). These stubs keep the Tool hook-ops adapter +//! total (verify/setup/remove/settings-path) without exposing a dead hook +//! surface: `hook_tools()` already filters on `spec.hooks.names.is_empty()`, +//! so Hermes is invisible to `hcom hooks` status/add/remove. + +use std::path::PathBuf; + +/// Hermes does not participate in the hook system, so verification trivially +/// passes (there is nothing to verify). +pub fn verify_hermes_hooks_installed(_include_permissions: bool) -> bool { + true +} + +/// Hermes has no hooks to install. +pub fn try_setup_hermes_hooks(_include_permissions: bool) -> anyhow::Result<()> { + Ok(()) +} + +/// Hermes has no hooks to remove. +pub fn remove_hermes_hooks() -> bool { + true +} + +/// Hermes config root: HERMES_HOME if set (the launcher surfaces it), else +/// `~/.hermes` (the platform-native default). +fn hermes_config_dir() -> std::path::PathBuf { + if let Ok(dir) = std::env::var("HERMES_HOME") + && !dir.is_empty() + { + return std::path::PathBuf::from(dir); + } + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".hermes") +} + +/// Path the hook integration would write to. Hermes keeps no hcom hook files; +/// return the hooks dir under HERMES_HOME for symmetry with other tools. +pub fn get_hermes_hooks_path() -> PathBuf { + hermes_config_dir().join("hooks") +} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index ef760cf6..4ede78ff 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -9,6 +9,7 @@ pub mod copilot; pub mod cursor; pub mod family; pub mod gemini; +pub mod hermes; pub mod kimi; pub mod opencode; pub mod pi; diff --git a/src/integration_spec.rs b/src/integration_spec.rs index 78b05711..88e635ca 100644 --- a/src/integration_spec.rs +++ b/src/integration_spec.rs @@ -139,6 +139,12 @@ pub enum ResumeArgs { Flag(&'static str), /// `resume ` (Codex subcommand). Subcommand(&'static str), + /// Resume is handled by the delivery loop over the tool's protocol, not by + /// CLI args. Hermes ACP: the delivery loop issues `session/load ` + /// instead of `session/new`; the id is threaded through the + /// `HCOM_HERMES_ACP_RESUME` env var on the PTY host process. `hermes acp` + /// accepts no resume flags of its own (see `hermes acp --help`). + JsonRpc, } /// Per-tool fork argument shape. @@ -405,6 +411,14 @@ const COPILOT_HELP_EXAMPLES: &[HelpEntry] = &[ ), ]; +const HERMES_HELP_EXAMPLES: &[HelpEntry] = &[ + ("hcom hermes acp", "Run Hermes over ACP (JSON-RPC stdio)"), + ( + "hcom send @ -- \"task\"", + "Deliver a task to a Hermes session", + ), +]; + // ── Per-tool integration constants ────────────────────────────────────── pub static CLAUDE: IntegrationSpec = IntegrationSpec { @@ -1059,6 +1073,85 @@ pub static COPILOT: IntegrationSpec = IntegrationSpec { }, }; +pub static HERMES: IntegrationSpec = IntegrationSpec { + tool: Tool::Hermes, + name: "hermes", + label: "Hermes", + aliases: &[], + cli_binary: "hermes", + tui_prefix: "her ", + adhoc_icon: None, + released: true, + // `hermes acp` is a JSON-RPC stdio server: there is no scraped on-screen + // ready marker. Empty pattern => is_ready() is always true, so launch + // readiness is NOT gated on scraped chrome — it is proven by the ACP + // `initialize` response in the delivery loop (emit_launch_ready_once). + ready_pattern: b"", + pty: PtySpec { + // hermes acp prints nothing to stdout until it receives `initialize`; + // delivery starts on this timeout so the loop can begin the handshake. + delivery_start_timeout_secs: 10, + }, + // HERMES_HOME (the acp session store) is instance state only in the sense + // that a same-tool child must not inherit it; it is set per-launch by the + // PTY host and stripped by the launcher's child-env cleanup. The ACP + // resume id is threaded via HCOM_HERMES_ACP_RESUME (see ResumeArgs::JsonRpc). + instance_state_env: &["HCOM_HERMES_ACP_RESUME"], + hooks: HooksSpec { + // Hermes has no hcom hook bridge; the ACP delivery loop replaces hook + // delivery (pending messages are pulled directly by the loop). + names: &[], + shared_hooks_with: None, + invocation: HookInvocation::None, + }, + // ACP delivery owns all runtime gating: idle = a prior session/prompt + // response has been fully drained; approvals are answered over JSON-RPC, + // not screen-scraped. Every gate stays off so the delivery loop is the + // single authority. + gates: GatesSpec { + require_idle: false, + require_ready_prompt: false, + require_prompt_empty: false, + block_on_user_activity: false, + block_on_approval: false, + launch_requires_ready: false, + launch_ready_on_plugin_bind: false, + }, + launch: LaunchSpec { + args_env: Some("HCOM_HERMES_ARGS"), + // Read by the launch diagnostic dump. Launcher config-isolation is a + // no-op (isolated_tool_config_dir returns None for Hermes): `hermes acp` + // needs the real HERMES_HOME (auth.json, state.db, config.yaml) and the + // delivery loop resolves readiness from `initialize`, so there is no + // per-project config tree to point at. + config_dir_env: Some("HERMES_HOME"), + initial_prompt: InitialPromptShape::Unsupported { + reason: "hermes delivers prompts over the ACP session/prompt method after the session is created. Launch `hcom hermes acp`, then send the task with `hcom send @ -- \"…\"`.", + }, + uses_pty_default: true, + max_launch_count: 10, + background: BackgroundMode::HeadlessPty, + }, + resume: Some(ResumeSpec { + // Resume is session/load over JSON-RPC (the delivery loop issues it); + // `hermes acp` takes no resume CLI flag. + resume: ResumeArgs::JsonRpc, + // No CLI fork primitive under acp; `session/fork` support is deferred. + fork: None, + }), + help: HelpSpec { + unique_examples: HERMES_HELP_EXAMPLES, + extra_env: &[], + }, + // Hermes built-in coding tools: shell is `terminal`, file ops are + // `write_file`/`patch`, delegation is `delegate_task`. + status_detail: StatusDetailSpec { + bash: &["terminal"], + file: &["write_file", "patch"], + delegate: &["delegate_task"], + }, +}; + pub static ADHOC: IntegrationSpec = IntegrationSpec { tool: Tool::Adhoc, name: "adhoc", @@ -1123,6 +1216,7 @@ pub static ALL: &[&IntegrationSpec] = &[ &CURSOR, &KIMI, &COPILOT, + &HERMES, &ADHOC, ]; @@ -1141,6 +1235,7 @@ impl Tool { Tool::Cursor => &CURSOR, Tool::Kimi => &KIMI, Tool::Copilot => &COPILOT, + Tool::Hermes => &HERMES, Tool::Adhoc => &ADHOC, } } @@ -1188,6 +1283,7 @@ mod tests { Tool::Cursor, Tool::Kimi, Tool::Copilot, + Tool::Hermes, Tool::Pi, Tool::Omp, Tool::Adhoc, @@ -1256,7 +1352,8 @@ mod tests { assert!(names.contains(&"kimi")); assert!(names.contains(&"copilot")); assert!(names.contains(&"omp")); - assert_eq!(names.len(), 11); + assert!(names.contains(&"hermes")); + assert_eq!(names.len(), 12); } #[test] diff --git a/src/launcher.rs b/src/launcher.rs index 5a829e8f..582fc31e 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -45,6 +45,7 @@ pub enum LaunchTool { Kimi, Copilot, Omp, + Hermes, } impl LaunchTool { @@ -63,6 +64,7 @@ impl LaunchTool { "cursor" | "cursor-agent" => Ok(LaunchTool::Cursor), "kimi" => Ok(LaunchTool::Kimi), "copilot" => Ok(LaunchTool::Copilot), + "hermes" => Ok(LaunchTool::Hermes), _ => bail!("Unknown tool: {}", s), } } @@ -81,6 +83,7 @@ impl LaunchTool { LaunchTool::Cursor => "cursor", LaunchTool::Kimi => "kimi", LaunchTool::Copilot => "copilot", + LaunchTool::Hermes => "hermes", } } @@ -101,6 +104,7 @@ impl LaunchTool { LaunchTool::Cursor => crate::tool::Tool::Cursor, LaunchTool::Kimi => crate::tool::Tool::Kimi, LaunchTool::Copilot => crate::tool::Tool::Copilot, + LaunchTool::Hermes => crate::tool::Tool::Hermes, } } @@ -170,7 +174,8 @@ impl LaunchBackend { | LaunchTool::Antigravity | LaunchTool::Cursor | LaunchTool::Kimi - | LaunchTool::Copilot => LaunchBackend::HeadlessPty, + | LaunchTool::Copilot + | LaunchTool::Hermes => LaunchBackend::HeadlessPty, } } } @@ -448,7 +453,12 @@ fn isolated_tool_config_dir(tool: &LaunchTool) -> Option { crate::tool::Tool::Cursor => ".cursor", crate::tool::Tool::Kimi => ".kimi", crate::tool::Tool::Copilot => ".copilot", - crate::tool::Tool::OpenCode | crate::tool::Tool::Adhoc => return None, + // Hermes must use the real HERMES_HOME (auth.json, state.db, + // config.yaml) — a fresh per-project tree would have no credentials + // and `hermes acp` would fail setup. No isolation, like OpenCode. + crate::tool::Tool::Hermes | crate::tool::Tool::OpenCode | crate::tool::Tool::Adhoc => { + return None; + } }; Some(root.join(dirname)) } @@ -770,6 +780,10 @@ fn ensure_hooks_installed(tool: &LaunchTool, include_permissions: bool) -> Resul } Ok(()) } + // Hermes has no hook surface — nothing to ensure (the ACP delivery + // loop replaces hook delivery). verify trivially passes, so this arm + // is only reachable via the match's exhaustiveness, never in practice. + LaunchTool::Hermes => Ok(()), } } @@ -2358,6 +2372,33 @@ pub fn launch(db: &HcomDb, mut params: LaunchParams) -> Result { inside_ai_tool, ) } + LaunchTool::Hermes => { + instances::update_instance_position( + db, + &instance_name, + &serde_json::Map::from_iter([( + "launch_args".to_string(), + json!(&stored_launch_args), + )]), + ); + launch_pty_or_background( + &mut BackgroundLaunchCtx { + db, + tool: "hermes", + instance_name: &instance_name, + process_id: &process_id, + terminal_mode, + tag: params.tag.as_deref().unwrap_or(""), + working_dir, + log_files: &mut log_files, + handles: &mut handles, + }, + &mut instance_env, + ¶ms.args, + ¶ms, + inside_ai_tool, + ) + } } })(); @@ -2456,6 +2497,7 @@ pub(crate) fn validate_tool_args(tool: &LaunchTool, args: &[String]) -> Vec crate::tools::copilot_preprocessing::validate_copilot_args(args), + LaunchTool::Hermes => crate::tools::launch_arg_validation::validate_hermes_args(args), } } diff --git a/src/main.rs b/src/main.rs index 832578eb..4777dd35 100644 --- a/src/main.rs +++ b/src/main.rs @@ -168,6 +168,7 @@ pub fn run_pty(args: &[String]) -> Result<()> { // Create and run PTY let instance_name_for_failure = instance_name.clone(); + let is_hermes_acp = tool_str == "hermes" && tool_args.first().copied() == Some("acp"); let mut proxy = match pty::Proxy::spawn( &command, &full_args, @@ -175,7 +176,36 @@ pub fn run_pty(args: &[String]) -> Result<()> { ready_pattern, instance_name, target, - env_vars: pty_child_env(), + // hermes acp is a JSON-RPC stdio server: the PTY slave must be raw + // (no echo / CRLF mangling) and hermes' stderr logs must be diverted + // to a file so the master stream stays pure JSON-RPC for delivery. + raw_pty: is_hermes_acp, + stderr_path: if is_hermes_acp { + Some(crate::paths::hcom_path(&[ + ".tmp", + "logs", + &format!( + "{}-hermes-acp.log", + instance_name_for_failure.as_deref().unwrap_or("hermes") + ), + ])) + } else { + None + }, + env_vars: { + let mut vars = pty_child_env(); + if is_hermes_acp { + // Hermes skips its globally-configured MCP servers when the + // host says so; hcom owns the session's cwd and the user's + // prompt turns, so a slow/stuck global MCP server must not + // delay the initialize handshake. + vars.push(( + "HERMES_ACP_SKIP_CONFIGURED_MCP".to_string(), + "1".to_string(), + )); + } + vars + }, }, ) { Ok(proxy) => proxy, diff --git a/src/pty/inject.rs b/src/pty/inject.rs index 14cdebe1..b523ce20 100644 --- a/src/pty/inject.rs +++ b/src/pty/inject.rs @@ -9,6 +9,11 @@ use std::os::fd::AsRawFd; /// Magic prefix for query commands (not injection) const QUERY_PREFIX: u8 = 0x00; +/// Magic prefix for raw injection: the payload passes through verbatim, keeping +/// any trailing newline. Used by the Hermes ACP delivery loop, whose +/// newline-delimited JSON-RPC framing requires an intact trailing `\n`. +pub(crate) const RAW_PREFIX: u8 = 0x01; + /// Result of reading from an inject client pub enum InjectResult { /// Text to inject into PTY @@ -111,6 +116,12 @@ impl InjectServer { /// - ScreenQuery(index): caller should dump screen and call respond_query() /// - Pending: no data ready yet pub fn read_client(&mut self, index: usize) -> Result { + // Ensure any pending connections are accepted so that the requested index is valid. + while self.clients.len() <= index { + if !self.accept()? { + break; + } + } if index >= self.clients.len() { return Ok(InjectResult::Pending); } @@ -151,26 +162,60 @@ impl InjectServer { } } + // If the client closed its write side without a zero‑length read (e.g., + // on platforms where shutdown triggers WouldBlock first), treat any buffered + // data as complete. + if !buffer.is_empty() { + let data = std::mem::take(buffer); + // Process possible query command. + if data.first() == Some(&QUERY_PREFIX) { + let cmd = std::str::from_utf8(&data[1..]).unwrap_or("").trim(); + let (stream, _) = self.clients.remove(index); + let command = match cmd { + "SCREEN" => QueryCommand::Screen, + _ => QueryCommand::Unknown, + }; + return Ok(InjectResult::Query(QueryClient { stream, command })); + } + self.clients.remove(index); + return Ok(InjectResult::Inject(self.process_inject_data(&data))); + } Ok(InjectResult::Pending) } /// Process injection data: decode and strip trailing LF /// Fix #7: Use UTF-8 with Latin-1 fallback instead of lossy (which mangles bytes) fn process_inject_data(&self, data: &[u8]) -> String { - let mut text = match String::from_utf8(data.to_vec()) { + // Determine if this is a raw injection (prefix 0x01). + let (raw, payload) = match data.first() { + Some(&RAW_PREFIX) => (true, &data[1..]), + _ => (false, data), + }; + + // Decode payload, falling back to Latin‑1 if UTF‑8 is invalid. + let decoded = match String::from_utf8(payload.to_vec()) { Ok(s) => s, - Err(_) => { - // Fallback to Latin-1 (preserves all byte values as chars) - data.iter().map(|&b| b as char).collect() - } + Err(_) => payload.iter().map(|&b| b as char).collect(), }; - // Strip single trailing LF (from echo/nc), preserve CR for submit - if text.ends_with('\n') { - text.pop(); + if raw { + // If the payload is valid UTF‑8 and contains no control characters (except + // the framing newline), we can return it unchanged – the newline is kept. + let utf8_ok = String::from_utf8(payload.to_vec()).is_ok(); + let has_control = decoded.chars().any(|c| (c as u32) <= 0x1F && c != '\n'); + if utf8_ok && !has_control { + return decoded; + } + // Raw injection: pass through verbatim (including newline and control bytes). + return decoded; } - text + // Non‑raw injection: strip a single trailing LF (typical of echo/nc), preserve CR. + let mut s = decoded; + if s.ends_with('\n') { + s.pop(); + } + s } } @@ -240,4 +285,48 @@ mod tests { assert_eq!(read_next(&mut server), "text"); assert_eq!(read_next(&mut server), "\r"); } + + #[test] + fn raw_prefix_preserves_trailing_newline() { + let mut server = InjectServer::new().unwrap(); + let mut client = TcpStream::connect(("127.0.0.1", server.port())).unwrap(); + let json_line = br#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}"#; + let mut payload = Vec::new(); + payload.push(super::RAW_PREFIX); + payload.extend_from_slice(json_line); + payload.push(b'\n'); + client.write_all(&payload).unwrap(); + client.shutdown(Shutdown::Write).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(1); + loop { + if let InjectResult::Inject(text) = server.read_client(0).unwrap() { + let expected = String::from_utf8(json_line.to_vec()).unwrap(); + assert_eq!(text, expected + "\n"); + break; + } + assert!(Instant::now() < deadline, "raw client did not complete"); + thread::sleep(Duration::from_millis(5)); + } + } + + #[test] + fn raw_prefix_keeps_c0_bytes() { + let mut server = InjectServer::new().unwrap(); + let mut client = TcpStream::connect(("127.0.0.1", server.port())).unwrap(); + client + .write_all(&[super::RAW_PREFIX, b'a', b'\x1b', b'b', b'\n']) + .unwrap(); + client.shutdown(Shutdown::Write).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(1); + loop { + if let InjectResult::Inject(text) = server.read_client(0).unwrap() { + assert_eq!(text, "a\u{1b}b\n"); + break; + } + assert!(Instant::now() < deadline, "raw client did not complete"); + thread::sleep(Duration::from_millis(5)); + } + } } diff --git a/src/pty/mod.rs b/src/pty/mod.rs index 7ed0d925..8505459d 100644 --- a/src/pty/mod.rs +++ b/src/pty/mod.rs @@ -8,6 +8,7 @@ //! - Delivery: Notify-driven message delivery (integrated) mod inject; +pub(crate) use inject::RAW_PREFIX; pub mod screen; #[cfg(any(unix, windows))] mod shared; @@ -587,6 +588,16 @@ pub struct ProxyConfig { pub target: PtyTarget, /// Extra environment variables to set in the child process pub env_vars: Vec<(String, String)>, + /// Run the PTY slave in raw mode: no input echo, no canonical line + /// buffering, no ONLCR `\n`→`\r\n` output translation. Required for + /// JSON-RPC stdio servers (hermes acp) where the master stream must + /// round-trip byte-clean. Unix-only; a no-op on Windows. + pub raw_pty: bool, + /// When set, the child's stderr is redirected to this file instead of the + /// PTY slave. Keeps human-readable logs out of a protocol-clean master + /// stream (hermes logs to stderr, so under a PTY they would otherwise + /// interleave with JSON-RPC frames on stdout). + pub stderr_path: Option, } impl Default for ProxyConfig { @@ -596,6 +607,8 @@ impl Default for ProxyConfig { instance_name: None, target: PtyTarget::Known(Tool::Claude), env_vars: vec![], + raw_pty: false, + stderr_path: None, } } } @@ -645,6 +658,37 @@ impl Proxy { let terminal_guard = TerminalGuard::new()?; terminal::setup_signal_handlers()?; + // For protocol-clean tools (hermes acp), run the slave in raw mode so + // injected bytes are not echoed back and child output is not CRLF-mangled. + // Termios is a property of the tty device and is set here (master side) + // before the child opens the slave. + if config.raw_pty { + let mut termios = + nix::sys::termios::tcgetattr(&pty.slave).context("tcgetattr failed")?; + nix::sys::termios::cfmakeraw(&mut termios); + nix::sys::termios::tcsetattr(&pty.slave, nix::sys::termios::SetArg::TCSANOW, &termios) + .context("tcsetattr (raw mode) failed")?; + } + + // Open stderr log file in the parent and hand the fd to the child via + // dup2 in pre_exec (raw fd i32 is Copy, safe across fork). + let stderr_file = match &config.stderr_path { + Some(path) => { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + Some( + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("open stderr log {:?} failed", path))?, + ) + } + None => None, + }; + let stderr_raw = stderr_file.as_ref().map(|f| f.as_raw_fd()); + // Spawn child process let slave_fd = pty.slave.as_raw_fd(); let master_fd = pty.master.as_raw_fd(); @@ -683,7 +727,16 @@ impl Proxy { if libc::dup2(slave_fd, 1) == -1 { return Err(io::Error::last_os_error()); } - if libc::dup2(slave_fd, 2) == -1 { + if let Some(stderr_fd) = stderr_raw { + // Keep logs off the protocol stream (hermes acp). + if libc::dup2(stderr_fd, 2) == -1 { + return Err(io::Error::last_os_error()); + } + // Close the inherited log fd if it's not a stdio slot. + if stderr_fd > 2 { + libc::close(stderr_fd); + } + } else if libc::dup2(slave_fd, 2) == -1 { return Err(io::Error::last_os_error()); } // Close slave fd if it's not stdio @@ -1072,6 +1125,9 @@ impl Proxy { self.screen.process(raw); } if !raw_chunks.is_empty() { + // Preserve the raw byte stream for protocol tools + // (hermes acp JSON-RPC delivery). + shared::append_raw_output(&self.delivery_state, &raw_chunks); shared::update_delivery_state( &self.delivery_state, &self.screen, diff --git a/src/pty/screen.rs b/src/pty/screen.rs index 6b630204..51610c37 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -606,6 +606,7 @@ impl ScreenTracker { Ok(Tool::Cursor) => self.get_cursor_input_text(), Ok(Tool::Kimi) => self.get_kimi_input_text(), Ok(Tool::Copilot) => self.get_copilot_input_text(), + Ok(Tool::Hermes) => None, // ACP delivery loop, no PTY input detection Ok(Tool::Adhoc) => None, Err(_) => None, } diff --git a/src/pty/shared.rs b/src/pty/shared.rs index 6805e906..2383abc3 100644 --- a/src/pty/shared.rs +++ b/src/pty/shared.rs @@ -34,6 +34,29 @@ use super::screen::ScreenTracker; /// enables this for Claude. pub(super) const USER_ACTIVITY_COOLDOWN_MS: u64 = 500; +/// Append raw PTY output to the shared screen state (capped ring buffer). +/// +/// Only the Hermes ACP delivery loop consumes this; every other tool ignores it. +/// Called by the PTY proxy (Unix read loop and Windows reader thread) for each +/// batch of chunks read from the master, so the JSON-RPC stream is preserved +/// byte-for-byte even when it has no screen-grid meaning. +pub(super) fn append_raw_output(screen_state: &Arc>, chunks: &[Vec]) { + if chunks.is_empty() { + return; + } + let cap = crate::delivery::RAW_OUTPUT_CAP; + if let Ok(mut state) = screen_state.write() { + for chunk in chunks { + state.raw_output.extend_from_slice(chunk); + } + let len = state.raw_output.len(); + if len > cap { + let excess = len - cap; + state.raw_output.drain(..excess); + } + } +} + /// Update shared delivery state from screen tracker. /// /// `publish` is the caller's approval-status publisher (it owns the diff --git a/src/pty/win.rs b/src/pty/win.rs index a6685b9b..b4076a01 100644 --- a/src/pty/win.rs +++ b/src/pty/win.rs @@ -643,6 +643,10 @@ impl Proxy { screen.process(data); + // Preserve the raw byte stream for protocol tools + // (hermes acp JSON-RPC delivery). + shared::append_raw_output(&screen_state, &[data.to_vec()]); + // Refresh the `hcom term` snapshot, throttled to ≤10Hz so // heavy output doesn't spend the reader in screen dumps // (~150µs each). A chunk skipped here is marked dirty and diff --git a/src/router.rs b/src/router.rs index d208e323..b43aa09d 100644 --- a/src/router.rs +++ b/src/router.rs @@ -39,6 +39,7 @@ const COMMANDS: &[&str] = &[ "relay", "run", "update", + "completion", ]; fn is_command(name: &str) -> bool { @@ -104,6 +105,7 @@ fn dispatch_hook_for_tool(tool: Tool, hook: &str, args: &[String]) -> (i32, Stri crate::hooks::copilot::dispatch_copilot_hook_native(hook), String::new(), ), + Tool::Hermes => unreachable!("hermes has no hook bridge"), Tool::Adhoc => unreachable!("adhoc has no hooks"), } } @@ -608,6 +610,7 @@ pub fn dispatch() -> anyhow::Result<()> { | "relay" | "run" | "update" + | "completion" ) => { let exit_code = dispatch_native_command(cmd, args); @@ -883,6 +886,12 @@ fn dispatch_native_command(cmd: &str, args: &[String]) -> i32 { &cmd_argv, |args| crate::commands::update::cmd_update(&db, &args, Some(&ctx)) ), + "completion" => clap_dispatch!( + crate::commands::completion::CompletionArgs, + cmd, + &cmd_argv, + |args| crate::commands::completion::cmd_completion(&db, &args, Some(&ctx)) + ), _ => { // Should never happen — only matched commands reach here eprintln!("Error: Unknown native command '{cmd}'"); diff --git a/src/tool.rs b/src/tool.rs index 01b8d6d4..bcba3b9f 100644 --- a/src/tool.rs +++ b/src/tool.rs @@ -20,6 +20,7 @@ pub enum Tool { Cursor, Kimi, Copilot, + Hermes, Pi, Omp, Adhoc, @@ -103,6 +104,9 @@ impl Tool { Tool::Copilot => { crate::hooks::copilot::verify_copilot_hooks_installed(include_permissions) } + Tool::Hermes => { + crate::hooks::hermes::verify_hermes_hooks_installed(include_permissions) + } Tool::Pi => crate::hooks::pi::verify_pi_plugin_installed(), Tool::Omp => crate::hooks::omp::verify_omp_plugin_installed(), Tool::Adhoc => false, @@ -139,6 +143,8 @@ impl Tool { .map_err(|e| e.to_string()), Tool::Copilot => crate::hooks::copilot::try_setup_copilot_hooks(include_permissions) .map_err(|e| e.to_string()), + Tool::Hermes => crate::hooks::hermes::try_setup_hermes_hooks(include_permissions) + .map_err(|e| e.to_string()), Tool::Pi => match crate::hooks::pi::install_pi_plugin() { Ok(true) => Ok(()), Ok(false) => Err(String::new()), @@ -171,6 +177,7 @@ impl Tool { Tool::Cursor => Ok(crate::hooks::cursor::remove_cursor_hooks()), Tool::Kimi => Ok(crate::hooks::kimi::remove_kimi_hooks()), Tool::Copilot => Ok(crate::hooks::copilot::remove_copilot_hooks()), + Tool::Hermes => Ok(crate::hooks::hermes::remove_hermes_hooks()), Tool::Pi => crate::hooks::pi::remove_pi_plugin() .map(|_| true) .map_err(|e| e.to_string()), @@ -194,6 +201,7 @@ impl Tool { Tool::Cursor => crate::hooks::cursor::get_cursor_hooks_path(), Tool::Kimi => crate::hooks::kimi::get_kimi_settings_path(), Tool::Copilot => crate::hooks::copilot::get_copilot_hooks_path(), + Tool::Hermes => crate::hooks::hermes::get_hermes_hooks_path(), Tool::Pi => crate::hooks::pi::get_pi_plugin_path(), Tool::Omp => crate::hooks::omp::get_omp_plugin_path(), Tool::Adhoc => return String::new(), @@ -311,6 +319,24 @@ mod tests { assert_eq!(Tool::Antigravity.hooks(), Tool::Gemini.hooks()); } + #[test] + fn hermes_from_str_and_has_no_hooks() { + assert_eq!("hermes".parse::(), Ok(Tool::Hermes)); + assert!(Tool::Hermes.hooks().is_empty()); + assert!(!Tool::Hermes.owns_hook("poll")); + assert_eq!(Tool::from_hook_name("poll"), Some(Tool::Claude)); + assert_eq!(Tool::Hermes.as_str(), "hermes"); + assert!(Tool::Hermes.ready_pattern().is_empty()); + } + + #[test] + fn hermes_hook_ops_are_noops() { + assert!(Tool::Hermes.verify_hooks_installed(false)); + assert!(Tool::Hermes.try_setup_hooks(false).is_ok()); + assert_eq!(Tool::Hermes.remove_hooks(), Ok(true)); + assert!(!Tool::Hermes.hooks_settings_path().is_empty()); + } + #[test] fn kilo_shares_opencode_hooks() { assert_eq!(Tool::Kilo.hooks(), Tool::OpenCode.hooks()); diff --git a/src/tools/launch_arg_validation.rs b/src/tools/launch_arg_validation.rs index 141b809e..72a3009b 100644 --- a/src/tools/launch_arg_validation.rs +++ b/src/tools/launch_arg_validation.rs @@ -148,6 +148,103 @@ pub(crate) const ANTIGRAVITY_REJECTED_ARGS: &[RejectedArg] = &[ }, ]; +/// Hermes root subcommands / top-level flags that would leave ACP mode. +/// +/// `hcom hermes` is the ACP launch surface: the command line must start with +/// the `acp` subcommand. Anything else either starts the interactive TUI +/// (unmanaged, no delivery), exits immediately, or is a non-ACP surface. +pub(crate) const HERMES_REJECTED_ARGS: &[RejectedArg] = &[ + RejectedArg { + token: "chat", + reason: "starts the interactive TUI instead of the ACP stdio server", + kind: RejectedArgKind::RootSubcommand, + }, + RejectedArg { + token: "gateway", + reason: "starts the gateway service instead of the ACP stdio server", + kind: RejectedArgKind::RootSubcommand, + }, + RejectedArg { + token: "serve", + reason: "starts a server surface instead of the ACP stdio server", + kind: RejectedArgKind::RootSubcommand, + }, + RejectedArg { + token: "desktop", + reason: "starts the desktop surface instead of the ACP stdio server", + kind: RejectedArgKind::RootSubcommand, + }, + RejectedArg { + token: "gui", + reason: "starts a GUI surface instead of the ACP stdio server", + kind: RejectedArgKind::RootSubcommand, + }, + RejectedArg { + token: "send", + reason: "sends a message and exits instead of running the ACP stdio server", + kind: RejectedArgKind::RootSubcommand, + }, + RejectedArg { + token: "setup", + reason: "runs interactive setup and exits", + kind: RejectedArgKind::RootSubcommand, + }, + RejectedArg { + token: "sessions", + reason: "manages session history and exits", + kind: RejectedArgKind::RootSubcommand, + }, + RejectedArg { + token: "mcp", + reason: "manages MCP servers and exits", + kind: RejectedArgKind::RootSubcommand, + }, + RejectedArg { + token: "-r", + reason: "resumes a session in the interactive TUI, not the ACP stdio server", + kind: RejectedArgKind::Flag, + }, + RejectedArg { + token: "--resume", + reason: "resumes a session in the interactive TUI, not the ACP stdio server", + kind: RejectedArgKind::Flag, + }, + RejectedArg { + token: "--continue", + reason: "resumes a session in the interactive TUI, not the ACP stdio server", + kind: RejectedArgKind::Flag, + }, +]; + +/// Hermes is the ACP launch surface: the first token must be the `acp` +/// subcommand (or a benign help flag). `hermes acp` itself accepts only +/// `--accept-hooks`, `--version`, `--check`, `--setup`, `--setup-browser`, +/// `--yes` — everything else is a hermes root subcommand / top-level flag +/// that leaves ACP mode and is rejected here with a clear redirect. +pub(crate) fn validate_hermes_args(args: &[String]) -> Vec { + let Some(first) = args.first() else { + return vec![ + "hcom hermes requires the `acp` subcommand: run `hcom hermes acp` to start Hermes in ACP (JSON-RPC stdio) mode." + .to_string(), + ]; + }; + if matches!(first.as_str(), "acp" | "--help" | "-h") { + return Vec::new(); + } + if let Some(rule) = HERMES_REJECTED_ARGS + .iter() + .find(|rule| long_flag_matches(first, rule.token)) + { + return vec![format!( + "Hermes argument `{}` is not supported by `hcom hermes`: {}. Launch the hcom-managed ACP server with `hcom hermes acp` instead.", + rule.token, rule.reason + )]; + } + vec![format!( + "Hermes argument `{first}` is not supported by `hcom hermes`: hcom drives Hermes over ACP. Run `hcom hermes acp` instead." + )] +} + /// Match a CLI token against a flag, accepting the `--flag=value` equals form /// for long flags. Short flags (`-p`) and subcommand words match exactly only. /// Shared so per-tool validators (cursor/copilot) reject `--print=…` the same diff --git a/src/transcript/hermes.rs b/src/transcript/hermes.rs new file mode 100644 index 00000000..5a13fa6d --- /dev/null +++ b/src/transcript/hermes.rs @@ -0,0 +1,665 @@ +//! Hermes transcript parser (SQLite `state.db` under HERMES_HOME). +//! +//! Hermes persists sessions in a SQLite database (`state.db`) with `sessions` +//! and `messages` tables. `messages.content` is plain text, or a +//! `\x00json:`-prefixed JSON blob for structured (multimodal) content. +//! Assistant tool calls live in the `tool_calls` JSON column (OpenAI-style), +//! and tool results are `tool`-role rows carrying `tool_call_id` + `tool_name`. + +use std::path::{Path, PathBuf}; + +use regex::Regex; +use serde_json::Value; + +use super::opencode::TranscriptSearchMatch; +use super::shared::{ + Exchange, ToolUse, capture_tool_output, finalize_action_text, is_error_result, + normalize_tool_name, truncate_str, +}; + +/// Hermes JSON-encodes non-scalar message content under this sentinel prefix. +const CONTENT_JSON_PREFIX: &str = "\x00json:"; + +/// Resolve the Hermes state database: `$HERMES_HOME/state.db`, else +/// `~/.hermes/state.db`, only if it exists. +pub(crate) fn get_hermes_db_path() -> Option { + let home = if let Ok(dir) = std::env::var("HERMES_HOME") + && !dir.is_empty() + { + PathBuf::from(dir) + } else { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".hermes") + }; + let db = home.join("state.db"); + db.exists().then_some(db) +} + +/// Decode a stored `content` value: plain strings pass through; sentinel- +/// prefixed JSON (multimodal lists/dicts) is decoded to its JSON value. +fn decode_content(raw: &str) -> Value { + if let Some(payload) = raw.strip_prefix(CONTENT_JSON_PREFIX) { + return serde_json::from_str(payload).unwrap_or(Value::String(raw.to_string())); + } + Value::String(raw.to_string()) +} + +/// Extract human-readable text from decoded content (string, list of parts, +/// or nested dict) for use as user text or assistant action text. +fn content_to_text(content: &Value) -> String { + match content { + Value::String(s) => s.trim().to_string(), + Value::Array(parts) => parts + .iter() + .filter_map(|part| { + if part.get("type").and_then(Value::as_str) == Some("text") { + part.get("text").and_then(Value::as_str) + } else { + part.as_str() + } + }) + .map(str::trim) + .filter(|t| !t.is_empty()) + .collect::>() + .join("\n"), + Value::Object(map) => { + if let Some(text) = map.get("text").and_then(Value::as_str) { + return text.trim().to_string(); + } + if let Some(content) = map.get("content") { + return content_to_text(content); + } + String::new() + } + _ => String::new(), + } +} + +/// Display text for tool output: plain strings pass through unwrapped (no JSON +/// quoting), structured content is flattened like user text. +fn output_to_text(content: &Value) -> String { + match content { + Value::String(s) => s.trim().to_string(), + _ => content_to_text(content), + } +} + +/// Parse Hermes SQLite transcript database for one session. +/// +/// Exchanges are grouped around user messages; assistant text, tool calls, +/// and their `tool`-role results are attached to the surrounding turn. +pub(crate) fn parse_hermes_sqlite( + db_path: &Path, + session_id: &str, + last: usize, +) -> Result, String> { + let conn = + rusqlite::Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| format!("Cannot open Hermes DB: {e}"))?; + + let mut stmt = conn + .prepare( + "SELECT role, content, tool_call_id, tool_calls, tool_name, timestamp + FROM messages + WHERE session_id = ? AND active = 1 + ORDER BY id ASC", + ) + .map_err(|e| format!("Query error: {e}"))?; + + struct MsgRow { + role: String, + content: Option, + tool_call_id: Option, + tool_calls: Option, + tool_name: Option, + timestamp: f64, + } + + let messages: Vec = stmt + .query_map(rusqlite::params![session_id], |row| { + Ok(MsgRow { + role: row.get(0)?, + content: row.get(1)?, + tool_call_id: row.get(2)?, + tool_calls: row.get(3)?, + tool_name: row.get(4)?, + timestamp: row.get::<_, f64>(5).unwrap_or(0.0), + }) + }) + .map_err(|e| format!("Query error: {e}"))? + .filter_map(|r| r.ok()) + .filter_map(|row| { + if row.role == "system" { + return None; + } + Some(row) + }) + .collect(); + + if messages.is_empty() { + return Ok(Vec::new()); + } + + // Tool results (role=tool) carry tool_call_id; index them so assistant + // tool calls can be paired with their outputs. + let tool_results: std::collections::HashMap = messages + .iter() + .filter(|row| row.role == "tool") + .filter_map(|row| { + row.tool_call_id + .as_ref() + .filter(|id| !id.is_empty()) + .map(|id| (id.clone(), row)) + }) + .collect(); + + // Map of tool_call_id -> (name, is_error, output) built lazily per turn. + let mut exchanges = Vec::new(); + let mut position = 0; + + let user_indices: Vec = messages + .iter() + .enumerate() + .filter(|(_, row)| row.role == "user") + .map(|(i, _)| i) + .collect(); + + for (ui_pos, &user_idx) in user_indices.iter().enumerate() { + let next_user_idx = user_indices + .get(ui_pos + 1) + .copied() + .unwrap_or(messages.len()); + let user_row = &messages[user_idx]; + let user_content = decode_content(user_row.content.as_deref().unwrap_or("")); + let user_text = content_to_text(&user_content); + if user_text.is_empty() { + continue; + } + + let timestamp = if user_row.timestamp > 0.0 { + chrono::DateTime::from_timestamp(user_row.timestamp as i64, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_default() + } else { + String::new() + }; + + let mut action_parts: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + let mut tools: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + + for row in &messages[(user_idx + 1)..next_user_idx] { + match row.role.as_str() { + "assistant" => { + let content = decode_content(row.content.as_deref().unwrap_or("")); + let text = content_to_text(&content); + if !text.is_empty() { + action_parts.push(text); + } + if let Some(tool_calls_json) = &row.tool_calls { + if let Ok(tool_calls) = serde_json::from_str::(tool_calls_json) { + for call in tool_calls.as_array().into_iter().flatten() { + let name = call + .get("function") + .and_then(|f| f.get("name")) + .and_then(Value::as_str) + .or_else(|| call.get("name").and_then(Value::as_str)) + .unwrap_or("unknown"); + let id = call.get("id").and_then(Value::as_str).unwrap_or(""); + let arguments = call + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(Value::as_str) + .unwrap_or("{}"); + let normalized = normalize_tool_name(name); + let input: Value = + serde_json::from_str(arguments).unwrap_or(Value::Null); + + let file = extract_file(&input); + if let Some(fname) = file.clone() { + if !files.contains(&fname) { + files.push(fname); + } + } + let command = if normalized == "Bash" { + input + .get("command") + .and_then(Value::as_str) + .map(|s| s.to_string()) + } else { + None + }; + + let output = tool_results + .get(id) + .and_then(|result_row| { + let raw = decode_content( + result_row.content.as_deref().unwrap_or(""), + ); + Some((raw, result_row.tool_name.as_deref().unwrap_or(name))) + }) + .map(|(raw, _)| raw); + + let is_error = output + .as_ref() + .map(|raw| is_error_result(raw)) + .unwrap_or(false); + + if is_error { + let content = output + .as_ref() + .map(|raw| output_to_text(raw)) + .unwrap_or_default(); + errors.push(serde_json::json!({ + "tool": normalized, + "content": truncate_str(&content, 300), + })); + } + + tools.push(ToolUse { + name: normalized.to_string(), + is_error, + file, + command, + output: output + .and_then(|raw| capture_tool_output(&output_to_text(&raw))), + }); + } + } + } + } + "tool" => { + // Standalone tool results that couldn't be paired with an + // assistant tool call (compaction slices) still surface the + // tool name when present. + if let Some(tool_name) = &row.tool_name { + if tool_results + .get(row.tool_call_id.as_deref().unwrap_or("")) + .is_some() + { + continue; + } + let normalized = normalize_tool_name(tool_name); + let raw = decode_content(row.content.as_deref().unwrap_or("")); + let text = output_to_text(&raw); + let is_error = is_error_result(&raw); + if is_error { + errors.push(serde_json::json!({ + "tool": normalized, + "content": truncate_str(&text, 300), + })); + } + tools.push(ToolUse { + name: normalized.to_string(), + is_error, + file: None, + command: None, + output: capture_tool_output(&text), + }); + } + } + _ => {} + } + } + + position += 1; + files.truncate(5); + + let ended_on_error = tools.last().map(|t| t.is_error).unwrap_or(false); + let action = + finalize_action_text(&action_parts.join("\n"), &tools, &errors, ended_on_error); + + exchanges.push(Exchange { + position, + user: user_text, + action, + files, + timestamp, + tools, + edits: Vec::new(), + errors, + ended_on_error, + }); + } + + if exchanges.len() > last { + let skip = exchanges.len() - last; + exchanges = exchanges.into_iter().skip(skip).collect(); + } + + Ok(exchanges) +} + +/// Extract a file name from a tool-call input object, if present. +fn extract_file(input: &Value) -> Option { + let obj = input.as_object()?; + for field in ["file_path", "filePath", "path", "file", "target_file"] { + if let Some(val) = obj.get(field).and_then(Value::as_str) + && !val.is_empty() + { + return Some( + Path::new(val) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(val) + .to_string(), + ); + } + } + None +} + +/// Search Hermes sessions for a regex pattern. +pub(crate) fn search_hermes_sessions( + db_path: &Path, + pattern: &str, + limit: usize, +) -> Result, String> { + let re = Regex::new(pattern).map_err(|e| format!("Invalid regex: {e}"))?; + let conn = + rusqlite::Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .map_err(|e| format!("Cannot open Hermes DB: {e}"))?; + + let mut stmt = conn + .prepare( + "SELECT m.session_id, COALESCE(s.title, ''), m.content, m.tool_name + FROM messages m + JOIN sessions s ON s.id = m.session_id + WHERE m.active = 1 AND m.role = 'user' + ORDER BY m.id ASC", + ) + .map_err(|e| format!("Query error: {e}"))?; + + let mut by_session: std::collections::HashMap = + std::collections::HashMap::new(); + let mut order = Vec::new(); + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }) + .map_err(|e| format!("Query error: {e}"))?; + + for row in rows { + let (session_id, title, content, tool_name) = match row { + Ok(row) => row, + Err(_) => continue, + }; + let raw = content.unwrap_or_default(); + let decoded = decode_content(&raw); + let text = content_to_text(&decoded); + if text.is_empty() || !re.is_match(&text) { + continue; + } + + let entry = by_session.entry(session_id.clone()).or_insert_with(|| { + order.push(session_id.clone()); + TranscriptSearchMatch { + path: db_path.to_string_lossy().to_string(), + agent: "hermes".to_string(), + line: 0, + text: truncate_str(&text.replace('\n', " "), 100).to_string(), + matches: 0, + session_id: Some(session_id.clone()), + label: Some(if title.is_empty() { + session_id.clone() + } else { + title + }), + } + }); + entry.matches += 1; + let _ = tool_name; + } + + Ok(order + .into_iter() + .filter_map(|session_id| by_session.remove(&session_id)) + .take(limit) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_db() -> tempfile::TempDir { + tempfile::tempdir().unwrap() + } + + fn create_tables(conn: &rusqlite::Connection) { + conn.execute_batch( + "CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + title TEXT, + message_count INTEGER DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + compacted INTEGER NOT NULL DEFAULT 0 + );", + ) + .unwrap(); + } + + fn insert( + conn: &rusqlite::Connection, + session_id: &str, + role: &str, + content: Option<&str>, + tool_call_id: Option<&str>, + tool_calls: Option<&str>, + tool_name: Option<&str>, + ts: f64, + ) { + conn.execute( + "INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + session_id, + role, + content, + tool_call_id, + tool_calls, + tool_name, + ts + ], + ) + .unwrap(); + } + + #[test] + fn parses_basic_hermes_session() { + let dir = make_db(); + let db_path = dir.path().join("state.db"); + let conn = rusqlite::Connection::open(&db_path).unwrap(); + create_tables(&conn); + conn.execute( + "INSERT INTO sessions (id, source, title) VALUES ('ses_1', 'acp', 'test')", + [], + ) + .unwrap(); + insert( + &conn, + "ses_1", + "user", + Some("fix the bug"), + None, + None, + None, + 1.0, + ); + insert( + &conn, + "ses_1", + "assistant", + Some("Let me check."), + None, + None, + None, + 2.0, + ); + insert( + &conn, + "ses_1", + "assistant", + None, + None, + Some( + r#"[{"id":"call_1","type":"function","function":{"name":"Bash","arguments":"{\"command\":\"cargo test\"}"}}]"#, + ), + None, + 3.0, + ); + insert( + &conn, + "ses_1", + "tool", + Some("All tests passed"), + Some("call_1"), + None, + Some("Bash"), + 4.0, + ); + insert( + &conn, + "ses_1", + "assistant", + Some("Done!"), + None, + None, + None, + 5.0, + ); + + let exchanges = parse_hermes_sqlite(&db_path, "ses_1", 10).unwrap(); + assert_eq!(exchanges.len(), 1); + assert_eq!(exchanges[0].user, "fix the bug"); + assert_eq!(exchanges[0].action, "Let me check.\nDone!"); + assert_eq!(exchanges[0].tools.len(), 1); + assert_eq!(exchanges[0].tools[0].name, "Bash"); + assert_eq!(exchanges[0].tools[0].command.as_deref(), Some("cargo test")); + assert_eq!( + exchanges[0].tools[0].output.as_deref(), + Some("All tests passed") + ); + assert!(!exchanges[0].ended_on_error); + } + + #[test] + fn skips_compacted_messages_and_json_content() { + let dir = make_db(); + let db_path = dir.path().join("state.db"); + let conn = rusqlite::Connection::open(&db_path).unwrap(); + create_tables(&conn); + conn.execute( + "INSERT INTO sessions (id, source, title) VALUES ('ses_2', 'acp', '')", + [], + ) + .unwrap(); + insert(&conn, "ses_2", "user", Some("hi"), None, None, None, 1.0); + // Compaction removes content but may leave a residual row; active=0 skips it. + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp, active) + VALUES ('ses_2', 'assistant', 'compacted', 2.0, 0)", + [], + ) + .unwrap(); + insert( + &conn, + "ses_2", + "user", + Some("\x00json:[{\"type\":\"text\",\"text\":\"multimodal ask\"}]"), + None, + None, + None, + 3.0, + ); + insert( + &conn, + "ses_2", + "assistant", + Some("ok"), + None, + None, + None, + 4.0, + ); + + let exchanges = parse_hermes_sqlite(&db_path, "ses_2", 10).unwrap(); + assert_eq!(exchanges.len(), 2); + assert_eq!(exchanges[0].action, "(no response)"); + assert_eq!(exchanges[1].user, "multimodal ask"); + assert_eq!(exchanges[1].action, "ok"); + } + + #[test] + fn search_matches_across_sessions() { + let dir = make_db(); + let db_path = dir.path().join("state.db"); + let conn = rusqlite::Connection::open(&db_path).unwrap(); + create_tables(&conn); + conn.execute( + "INSERT INTO sessions (id, source, title) VALUES ('ses_a', 'acp', 'PONG'), ('ses_b', 'acp', 'other')", + [], + ) + .unwrap(); + insert( + &conn, + "ses_a", + "user", + Some("reply with PONG"), + None, + None, + None, + 1.0, + ); + insert( + &conn, + "ses_b", + "user", + Some("hello world"), + None, + None, + None, + 2.0, + ); + + let matches = search_hermes_sessions(&db_path, "PONG", 10).unwrap(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].session_id.as_deref(), Some("ses_a")); + assert_eq!(matches[0].agent, "hermes"); + assert_eq!(matches[0].label.as_deref(), Some("PONG")); + } + + #[test] + fn no_tool_data_returns_empty_session() { + let dir = make_db(); + let db_path = dir.path().join("state.db"); + let conn = rusqlite::Connection::open(&db_path).unwrap(); + create_tables(&conn); + conn.execute( + "INSERT INTO sessions (id, source, title) VALUES ('empty', 'acp', '')", + [], + ) + .unwrap(); + let exchanges = parse_hermes_sqlite(&db_path, "empty", 10).unwrap(); + assert!(exchanges.is_empty()); + } +} diff --git a/src/transcript/mod.rs b/src/transcript/mod.rs index 460ae423..c0fc651d 100644 --- a/src/transcript/mod.rs +++ b/src/transcript/mod.rs @@ -10,6 +10,7 @@ pub mod codex; pub mod copilot; pub mod cursor; pub mod gemini; +pub mod hermes; pub mod kimi; pub mod opencode; pub mod pi; @@ -40,6 +41,7 @@ pub enum TranscriptBackend { KimiWireJsonl, CopilotJsonl, PiJsonl, + HermesSqlite, } /// Where `transcript search --all` discovers sessions for a tool. @@ -59,6 +61,7 @@ enum TranscriptDiscovery { CopilotSessionState, PiSessions, OmpSessions, + HermesStateDb, } #[derive(Debug, Clone, Copy)] @@ -124,6 +127,11 @@ static TRANSCRIPT_PROFILES: &[TranscriptProfile] = &[ backend: TranscriptBackend::CopilotJsonl, discovery: TranscriptDiscovery::CopilotSessionState, }, + TranscriptProfile { + tool: Tool::Hermes, + backend: TranscriptBackend::HermesSqlite, + discovery: TranscriptDiscovery::HermesStateDb, + }, ]; fn profile_for_tool(tool: Tool) -> Option<&'static TranscriptProfile> { @@ -217,6 +225,13 @@ pub fn read( copilot::parse_copilot_jsonl(path, opts.last, opts.detailed) } TranscriptBackend::PiJsonl => pi::parse_pi_jsonl(path, opts.last, opts.detailed), + TranscriptBackend::HermesSqlite => { + let sid = opts.session_id.as_deref().unwrap_or(""); + if sid.is_empty() { + return Err("Hermes transcript requires a session_id".to_string()); + } + hermes::parse_hermes_sqlite(path, sid, opts.last) + } TranscriptBackend::OpenCodeSqlite => { let sid = opts.session_id.as_deref().unwrap_or(""); if sid.is_empty() { @@ -291,6 +306,9 @@ pub fn detect_tool_from_path(path: &str) -> Option { Some(Tool::OpenCode) } else if file_name == "kilo.db" || lower.contains("/kilo/") { Some(Tool::Kilo) + } else if file_name == "state.db" && (lower.contains("/.hermes/") || lower.contains("/hermes/")) + { + Some(Tool::Hermes) } else if lower.contains("/.gemini/tmp/") && lower.contains("/chats/") && file_name.starts_with("session-") @@ -535,7 +553,9 @@ pub fn disk_search_roots(tool: Tool) -> Vec { } TranscriptDiscovery::PiSessions => pi_session_roots(), TranscriptDiscovery::OmpSessions => omp_session_roots(), - TranscriptDiscovery::OpenCodeDatabase | TranscriptDiscovery::KiloDatabase => Vec::new(), + TranscriptDiscovery::OpenCodeDatabase + | TranscriptDiscovery::KiloDatabase + | TranscriptDiscovery::HermesStateDb => Vec::new(), } } @@ -544,6 +564,7 @@ pub(crate) fn database_search_path(tool: Tool) -> Option { match profile_for_tool(tool)?.discovery { TranscriptDiscovery::OpenCodeDatabase => opencode::get_opencode_db_path(), TranscriptDiscovery::KiloDatabase => opencode::get_kilo_db_path(), + TranscriptDiscovery::HermesStateDb => hermes::get_hermes_db_path(), _ => None, } } @@ -563,6 +584,9 @@ pub(crate) fn search_database_sessions( Some(TranscriptDiscovery::KiloDatabase) => { opencode::search_kilo_sessions(db_path, pattern, limit) } + Some(TranscriptDiscovery::HermesStateDb) => { + hermes::search_hermes_sessions(db_path, pattern, limit) + } _ => Err(format!("Tool '{}' is not database-backed", tool)), } } @@ -722,7 +746,7 @@ mod tests { fn every_transcript_tool_has_a_disk_or_database_discovery_source() { for tool in transcript_tools() { let roots = disk_search_roots(tool); - if matches!(tool, Tool::OpenCode | Tool::Kilo) { + if matches!(tool, Tool::OpenCode | Tool::Kilo | Tool::Hermes) { assert!( roots.is_empty(), "database tool {tool} should not expose disk roots" diff --git a/src/tui/db.rs b/src/tui/db.rs index b558becd..34b4ef17 100644 --- a/src/tui/db.rs +++ b/src/tui/db.rs @@ -256,6 +256,7 @@ fn parse_tool(s: &str) -> Tool { Ok(crate::tool::Tool::Cursor) => Tool::Cursor, Ok(crate::tool::Tool::Kimi) => Tool::Kimi, Ok(crate::tool::Tool::Copilot) => Tool::Copilot, + Ok(crate::tool::Tool::Hermes) => Tool::Hermes, Ok(crate::tool::Tool::Adhoc) => Tool::Adhoc, Err(_) => Tool::Unknown(s.to_string()), } diff --git a/src/tui/model.rs b/src/tui/model.rs index 7d89bf1b..2aae61d3 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -88,6 +88,7 @@ pub enum Tool { Cursor, Kimi, Copilot, + Hermes, Adhoc, /// Persisted value written by a newer or third-party integration. Unknown(String), @@ -108,6 +109,7 @@ impl Tool { Self::Cursor => Some(crate::tool::Tool::Cursor), Self::Kimi => Some(crate::tool::Tool::Kimi), Self::Copilot => Some(crate::tool::Tool::Copilot), + Self::Hermes => Some(crate::tool::Tool::Hermes), Self::Adhoc => Some(crate::tool::Tool::Adhoc), Self::Unknown(_) => None, } @@ -142,7 +144,8 @@ impl Tool { Self::Antigravity => Self::Cursor, Self::Cursor => Self::Kimi, Self::Kimi => Self::Copilot, - Self::Copilot => Self::Claude, + Self::Copilot => Self::Hermes, + Self::Hermes => Self::Claude, Self::Adhoc => Self::Adhoc, Self::Unknown(raw) => Self::Unknown(raw.clone()), } @@ -151,7 +154,7 @@ impl Tool { /// Cycle backward (for launch panel). Adhoc is not launchable. pub fn prev(&self) -> Self { match self { - Self::Claude => Self::Copilot, + Self::Claude => Self::Hermes, Self::Gemini => Self::Claude, Self::Codex => Self::Gemini, Self::OpenCode => Self::Codex, @@ -162,6 +165,7 @@ impl Tool { Self::Cursor => Self::Antigravity, Self::Kimi => Self::Cursor, Self::Copilot => Self::Kimi, + Self::Hermes => Self::Copilot, Self::Adhoc => Self::Adhoc, Self::Unknown(raw) => Self::Unknown(raw.clone()), } @@ -1271,12 +1275,14 @@ mod tests { assert_eq!(Tool::Antigravity.next(), Tool::Cursor); assert_eq!(Tool::Cursor.next(), Tool::Kimi); assert_eq!(Tool::Kimi.next(), Tool::Copilot); - assert_eq!(Tool::Copilot.next(), Tool::Claude); + assert_eq!(Tool::Copilot.next(), Tool::Hermes); + assert_eq!(Tool::Hermes.next(), Tool::Claude); } #[test] fn tool_prev_cycles_backward() { - assert_eq!(Tool::Claude.prev(), Tool::Copilot); + assert_eq!(Tool::Claude.prev(), Tool::Hermes); + assert_eq!(Tool::Hermes.prev(), Tool::Copilot); assert_eq!(Tool::Copilot.prev(), Tool::Kimi); assert_eq!(Tool::Kimi.prev(), Tool::Cursor); assert_eq!(Tool::Cursor.prev(), Tool::Antigravity);