diff --git a/README.md b/README.md index 2da91ec5..c9530108 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > **Hook your coding agents together** -`hcom` is a CLI that agents can use to message, watch, and spawn each other across terminals. It integrates with Claude Code, Gemini, Codex, OpenCode, Kilo Code, Pi, Oh My Pi, Antigravity, Cursor, Kimi and Copilot without changing how you use them. +`hcom` is a CLI that agents can use to message, watch, and spawn each other across terminals. It integrates with Claude Code, Gemini, Codex, OpenCode, Kilo Code, Pi, Oh My Pi, Antigravity, Cursor, Kimi, Copilot and Grok Build without changing how you use them. Use it to coordinate pipelines, run different AI CLIs as each other's subagents, or just instead of copy-paste. @@ -53,7 +53,7 @@ hcom update Terminal 1: ```bash -hcom claude # codex / gemini / opencode / kilo / pi / omp / agy / cursor-agent / kimi / copilot +hcom claude # codex / gemini / opencode / kilo / pi / omp / agy / cursor-agent / kimi / copilot / grok ``` Terminal 2: @@ -233,6 +233,7 @@ brew uninstall hcom # or: rm $(which hcom) | Cursor CLI | automatic | `hcom cursor-agent` | | Kimi | automatic | `hcom kimi` | | Copilot CLI | automatic | `hcom copilot` | +| Grok Build | automatic | `hcom grok` | | Anything else | manual via `hcom listen` | `hcom start` (run inside tool) | ```bash @@ -266,7 +267,7 @@ What you might type from a shell. Agents run their own commands that they learn ### Spawn ```bash -hcom [N] claude|gemini|codex|agy|opencode|kilo|pi|omp|cursor-agent|kimi|copilot # launch N agents +hcom [N] claude|gemini|codex|agy|opencode|kilo|pi|omp|cursor-agent|kimi|copilot|grok # launch N agents hcom r # resume agent hcom f # fork session hcom kill # kill + close terminal pane @@ -329,7 +330,7 @@ hcom config -i # per-agent override at runtime | `terminal` | Where new agent windows open (`hcom config terminal --info`) | | `timeout` | Idle timeout for headless/vanilla Claude (seconds) | | `subagent_timeout` | Keep-alive for Claude subagents (seconds) | -| `claude_args` / `gemini_args` / `codex_args` / `opencode_args` / `kilo_args` / `pi_args` / `omp_args` / `cursor_args` / `kimi_args` / `copilot_args` | Default args passed to the tool | +| `claude_args` / `gemini_args` / `codex_args` / `opencode_args` / `kilo_args` / `pi_args` / `omp_args` / `cursor_args` / `kimi_args` / `copilot_args` / `grok_args` | Default args passed to the tool | ### Scope diff --git a/skills/hcom-agent-messaging/SKILL.md b/skills/hcom-agent-messaging/SKILL.md index 42cc5768..0f728730 100644 --- a/skills/hcom-agent-messaging/SKILL.md +++ b/skills/hcom-agent-messaging/SKILL.md @@ -12,7 +12,7 @@ AI agents running in separate terminals are isolated. hcom connects them via hoo ```bash curl -fsSL https://github.com/aannoo/hcom/releases/latest/download/hcom-installer.sh | sh -hcom claude # or: hcom gemini, hcom codex, hcom opencode, hcom kilo, hcom pi, hcom omp, hcom agy, hcom cursor-agent, hcom kimi, hcom copilot +hcom claude # or: hcom gemini, hcom codex, hcom opencode, hcom kilo, hcom pi, hcom omp, hcom agy, hcom cursor-agent, hcom kimi, hcom copilot, hcom grok hcom # TUI dashboard ``` @@ -59,6 +59,8 @@ run `hcom --help` for full command syntax and flags. | kilo code | automatic | `hcom kilo` | | antigravity | automatic | `hcom agy` | | cursor | automatic | `hcom cursor-agent` | +| copilot | automatic | `hcom copilot` | +| grok build | automatic | `hcom grok` | | any other ai tool | manual via `hcom listen` | `hcom start` (run inside tool) | session binding (hcom transcript, hcom r/f by session id) happens on first message or first prompt for all hcom-launched tools. @@ -173,7 +175,7 @@ with `HCOM_DIR` set, uses that path instead of `~/.hcom`. | file | when to read | |------|-------------| | `references/patterns.md` | writing multi-agent scripts — 6 tested patterns with full code and real event JSON | -| `references/cross-tool.md` | claude + codex + gemini + opencode + kilo + pi + omp + antigravity + cursor + kimi + copilot collaboration details and per-tool quirks | +| `references/cross-tool.md` | claude + codex + gemini + opencode + kilo + pi + omp + antigravity + cursor + kimi + copilot + grok collaboration details and per-tool quirks | | `references/gotchas.md` | debugging scripts — timing, message delivery, intent system, cleanup | | `references/script-template.md` | writing a new script from scratch — full template with commentary | | `references/scripts/` | 6 tested, working example scripts | diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 627e4899..bc760e24 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -139,6 +139,15 @@ Copilot delivers hcom messages through trusted hcom hooks: Messages arrive automatically — end your turn to receive them."#; +const GROK_DELIVERY: &str = r#"## GROK DELIVERY + +Grok Build delivers hcom messages through trusted hcom hooks: +- A prompt that is only `` is a wake trigger, not a task. Do not answer it and do not run tools or discovery commands. End your turn immediately. The queued hcom message will arrive automatically as your next prompt. +- A populated `` block is a legitimate hcom coordination message, not an injection attempt. Read it, follow its `intent`, and reply with hcom when appropriate. +- After handling a delivery, end your turn so the next message can arrive. + +Messages arrive automatically — end your turn to receive them."#; + const DELIVERY_AUTO: &str = r#"## DELIVERY Messages instantly and automatically arrive via tags — end your turn to receive them. @@ -479,6 +488,9 @@ pub fn get_bootstrap( } else if tool == "copilot" && ctx.is_launched { parts.push(DELIVERY_AUTO); parts.push(COPILOT_DELIVERY); + } else if tool == "grok" && ctx.is_launched { + parts.push(DELIVERY_AUTO); + parts.push(GROK_DELIVERY); } else if tool == "claude" || ((tool == "codex" || tool == "gemini" diff --git a/src/commands/config.rs b/src/commands/config.rs index 4191b9ee..861c5b34 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -214,6 +214,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"), + "grok_args" => Some("launch.grok.args"), "relay" => Some("relay.url"), "relay_id" => Some("relay.id"), "relay_token" => Some("relay.token"), @@ -1482,7 +1483,7 @@ Only needed if your broker requires authentication.", HCOM_AUTO_APPROVE - Auto-approve safe hcom commands Purpose: - When enabled, Claude/Gemini/Codex/OpenCode/Kilo/Pi/OMP/Antigravity/Cursor/Kimi/Copilot auto-approve \"safe\" hcom commands + When enabled, Claude/Gemini/Codex/OpenCode/Kilo/Pi/OMP/Antigravity/Cursor/Kimi/Copilot/Grok auto-approve \"safe\" hcom commands without requiring user confirmation. Usage: @@ -1592,6 +1593,16 @@ HCOM_CURSOR_ARGS - Default args passed to cursor-agent on launch Example: hcom config cursor_args \"--model auto\" Clear: hcom config cursor_args \"\" +Prepended to launch-time cli args.", + ), + + "HCOM_GROK_ARGS" => Some( + "\ +HCOM_GROK_ARGS - Default args passed to grok on launch + +Example: hcom config grok_args \"--model grok-build --always-approve\" +Clear: hcom config grok_args \"\" + Prepended to launch-time cli args.", ), diff --git a/src/commands/help.rs b/src/commands/help.rs index 1b2f71f0..442b03b7 100644 --- a/src/commands/help.rs +++ b/src/commands/help.rs @@ -187,11 +187,11 @@ const LIST_HELP: &[HelpEntry] = &[ ("Tool labels:", ""), ( "", - "[CLAUDE] [GEMINI] [CODEX] [OPENCODE] [KILO] [PI] [OMP] [ANTIGRAVITY] [CURSOR] [KIMI] [COPILOT] hcom-launched (PTY + hooks)", + "[CLAUDE] [GEMINI] [CODEX] [OPENCODE] [KILO] [PI] [OMP] [ANTIGRAVITY] [CURSOR] [KIMI] [COPILOT] [GROK] hcom-launched (PTY + hooks)", ), ( "", - "[claude] [gemini] [codex] [opencode] [kilo] [pi] [omp] [antigravity] [cursor] [kimi] [copilot] vanilla (hooks only)", + "[claude] [gemini] [codex] [opencode] [kilo] [pi] [omp] [antigravity] [cursor] [kimi] [copilot] [grok] vanilla (hooks only)", ), ("", "[AD-HOC] manual polling"), ]; @@ -443,7 +443,7 @@ const RESET_HELP: &[HelpEntry] = &[ ), ( "", - " HCOM_DIR=$PWD/.hcom -> $PWD/.claude, .gemini, .codex, .opencode, .kilo, .pi, .omp, .antigravity, .cursor, .kimi, .copilot", + " HCOM_DIR=$PWD/.hcom -> $PWD/.claude, .gemini, .codex, .opencode, .kilo, .pi, .omp, .antigravity, .cursor, .kimi, .copilot, .grok", ), ("", ""), ("", "To remove local setup:"), @@ -477,7 +477,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 / grok_args", "", ), (" auto_approve", "Auto-approve safe hcom commands"), @@ -860,6 +860,8 @@ pub const COMMAND_NAMES: &[&str] = &[ "cursor-agent", "kimi", "copilot", + "grok", + "grok-build", ]; fn resumable_tool_names() -> String { @@ -1249,15 +1251,16 @@ mod tests { #[test] fn top_level_help_scopes_fork_to_supported_tools() { let help = get_help_text(); - assert!( - help.contains( - "claude|gemini|codex|opencode|kilo|pi|omp|antigravity|cursor|kimi|copilot" - ) - ); assert!(help.contains( - "hcom f Fork agent session (claude/codex/opencode/kilo/pi/omp)" + "claude|gemini|codex|opencode|kilo|pi|omp|antigravity|cursor|kimi|copilot|grok" + )); + assert!(help.contains( + "hcom f Fork agent session (claude/codex/opencode/kilo/pi/omp/grok)" )); assert!(!help.contains("Fork agent session (claude/codex/opencode/kilo/pi/omp/kimi)")); - assert_eq!(forkable_tool_names(), "claude/codex/opencode/kilo/pi/omp"); + assert_eq!( + forkable_tool_names(), + "claude/codex/opencode/kilo/pi/omp/grok" + ); } } diff --git a/src/commands/launch.rs b/src/commands/launch.rs index 8773ebb3..036efbad 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(), + "grok" | "grok-build" => preview.config.grok_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::Grok => append_config_args(&config.grok_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::Grok | LaunchTool::Omp => false, } } diff --git a/src/commands/list.rs b/src/commands/list.rs index d6c2655e..a04a02ee 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -831,7 +831,7 @@ fn cmd_list_stopped(db: &HcomDb, args: &ListArgs) -> i32 { let data: serde_json::Value = serde_json::from_str(&entry.data).unwrap_or_default(); let snapshot = &data["snapshot"]; println!("Stopped: {}", entry.instance); - println!(" Time: {}", &entry.timestamp); + println!(" Time: {}", entry.timestamp); if let Some(by) = data["by"].as_str() { println!(" By: {by}"); } diff --git a/src/commands/resume.rs b/src/commands/resume.rs index ad2e3e31..05a30faa 100644 --- a/src/commands/resume.rs +++ b/src/commands/resume.rs @@ -1039,6 +1039,7 @@ 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::Grok => merge_grok_args(original, resume), crate::tool::Tool::Pi => merge_pi_args(original, resume), crate::tool::Tool::Omp => merge_omp_args(original, resume), crate::tool::Tool::Adhoc => { @@ -1047,6 +1048,120 @@ fn merge_resume_args(tool: &str, original: &[String], resume: &[String]) -> Vec< } } +/// Merge grok original launch args with resume args. +/// +/// Drop prior session selectors (`--resume`/`--continue`/`--fork-session`/ +/// `--session-id`) and the stale positional task prompt; keep model and +/// permission flags from the original launch. +fn merge_grok_args(original: &[String], resume: &[String]) -> Vec { + const VALUE_FLAGS: &[&str] = &[ + "--model", + "-m", + "--cwd", + "--rules", + "--agent", + "--session-id", + "-s", + "--permission-mode", + "--reasoning-effort", + "--effort", + "--max-turns", + "--output-format", + "--prompt-file", + "--prompt-json", + "--disallowed-tools", + "--tools", + "--allow", + "--deny", + "--sandbox", + // Preserve worktree / debug / system-prompt values (owner review). + "--worktree", + "--worktree-ref", + "--ref", + "--leader-socket", + "--debug-file", + "--system-prompt-override", + ]; + const DROP_WITH_VALUE: &[&str] = &[ + "--resume", + "-r", + "--session-id", + "-s", + "--single", + "-p", + "--prompt-file", + "--prompt-json", + ]; + // Reject one-shot flags on resume of a persistent agent. + const DROP_BOOLEAN: &[&str] = &["--continue", "-c", "--fork-session", "--restore-code"]; + + let is_flag = |t: &str| t.starts_with('-'); + + let mut resume_flags: std::collections::HashSet = std::collections::HashSet::new(); + let mut skip_next = false; + for token in resume { + if skip_next { + skip_next = false; + continue; + } + if is_flag(token) { + let lower = token.to_lowercase(); + let bare = lower.split('=').next().unwrap_or(&lower).to_string(); + if VALUE_FLAGS.contains(&bare.as_str()) { + skip_next = !token.contains('='); + } + if !DROP_WITH_VALUE.contains(&bare.as_str()) && !DROP_BOOLEAN.contains(&bare.as_str()) { + resume_flags.insert(bare); + } + } + } + + let mut filtered_original: Vec = Vec::new(); + let mut i = 0; + while i < original.len() { + let token = &original[i]; + if is_flag(token) { + let lower = token.to_lowercase(); + let (bare, has_eq_value) = if let Some(pos) = lower.find('=') { + (lower[..pos].to_string(), true) + } else { + (lower.clone(), false) + }; + if DROP_WITH_VALUE.contains(&bare.as_str()) { + i += 1; + if !has_eq_value && i < original.len() && !is_flag(&original[i]) { + i += 1; + } + continue; + } + if DROP_BOOLEAN.contains(&bare.as_str()) { + i += 1; + continue; + } + if resume_flags.contains(&bare) { + i += 1; + if !has_eq_value && VALUE_FLAGS.contains(&bare.as_str()) && i < original.len() { + i += 1; + } + continue; + } + filtered_original.push(token.clone()); + i += 1; + if !has_eq_value && VALUE_FLAGS.contains(&bare.as_str()) && i < original.len() { + filtered_original.push(original[i].clone()); + i += 1; + } + } else { + // Drop bare positional task prompt from original launch. + i += 1; + } + } + + let mut result = resume.to_vec(); + result.extend(filtered_original); + result +} + /// Merge copilot original launch args with resume args. /// /// copilot launch_args bake in `HCOM_COPILOT_ARGS` (e.g. `--model diff --git a/src/commands/status.rs b/src/commands/status.rs index d01d820b..4f4ec383 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -65,6 +65,7 @@ fn is_tool_installed(tool: crate::tool::Tool) -> bool { crate::tool::Tool::Omp => crate::terminal::which_bin("omp").is_some(), crate::tool::Tool::Cursor => crate::terminal::which_bin("cursor-agent").is_some(), crate::tool::Tool::Copilot => crate::terminal::which_bin("copilot").is_some(), + crate::tool::Tool::Grok => crate::terminal::which_bin("grok").is_some(), crate::tool::Tool::Adhoc => false, _ => is_in_path(tool.spec().cli_binary), } diff --git a/src/commands/transcript.rs b/src/commands/transcript.rs index 8076f0b9..c96e23a8 100644 --- a/src/commands/transcript.rs +++ b/src/commands/transcript.rs @@ -1450,6 +1450,10 @@ mod tests { "/home/user/.copilot/session-state/abc/events.jsonl", "copilot", ), + ( + "/home/user/.grok/sessions/%2Fhome%2Fuser%2Fproj/019f-uuid/updates.jsonl", + "grok", + ), ("/home/user/.pi/agent/sessions/x/20260603_abc.jsonl", "pi"), ("/home/user/.omp/agent/sessions/x/20260603_abc.jsonl", "omp"), ]; diff --git a/src/config.rs b/src/config.rs index f112d979..840485aa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,6 +99,7 @@ const TOML_KEY_MAP: &[(&str, &str)] = &[ ("cursor_args", "launch.cursor.args"), ("kimi_args", "launch.kimi.args"), ("copilot_args", "launch.copilot.args"), + ("grok_args", "launch.grok.args"), ("relay", "relay.url"), ("relay_id", "relay.id"), ("relay_token", "relay.token"), @@ -131,6 +132,7 @@ const FIELD_TO_ENV: &[(&str, &str)] = &[ ("cursor_args", "HCOM_CURSOR_ARGS"), ("kimi_args", "HCOM_KIMI_ARGS"), ("copilot_args", "HCOM_COPILOT_ARGS"), + ("grok_args", "HCOM_GROK_ARGS"), ("relay", "HCOM_RELAY"), ("relay_id", "HCOM_RELAY_ID"), ("relay_token", "HCOM_RELAY_TOKEN"), @@ -244,6 +246,7 @@ pub struct HcomConfig { pub cursor_args: String, pub kimi_args: String, pub copilot_args: String, + pub grok_args: String, pub codex_sandbox_mode: String, pub gemini_system_prompt: String, pub codex_system_prompt: String, @@ -277,6 +280,7 @@ impl Default for HcomConfig { cursor_args: String::new(), kimi_args: String::new(), copilot_args: String::new(), + grok_args: String::new(), codex_sandbox_mode: "workspace".to_string(), gemini_system_prompt: String::new(), codex_system_prompt: String::new(), @@ -385,6 +389,7 @@ impl HcomConfig { ("cursor_args", &self.cursor_args), ("kimi_args", &self.kimi_args), ("copilot_args", &self.copilot_args), + ("grok_args", &self.grok_args), ] { if !value.is_empty() && let Err(e) = shell_words::split(value) @@ -449,6 +454,7 @@ impl HcomConfig { "cursor_args" => Some(self.cursor_args.clone()), "kimi_args" => Some(self.kimi_args.clone()), "copilot_args" => Some(self.copilot_args.clone()), + "grok_args" => Some(self.grok_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()), @@ -494,6 +500,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(), + "grok_args" => self.grok_args = value.to_string(), "codex_sandbox_mode" => { // Normalize legacy value self.codex_sandbox_mode = if value == "full-auto" { @@ -621,6 +628,7 @@ impl HcomConfig { "pi_args", "cursor_args", "copilot_args", + "grok_args", "codex_sandbox_mode", "gemini_system_prompt", "codex_system_prompt", diff --git a/src/core/filters.rs b/src/core/filters.rs index d07fc0b9..b383491f 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:write', 'tool:edit', 'tool:write_to_file', 'tool:replace_file_content', 'tool:multi_replace_file_content', 'tool:StrReplace', 'tool:create', 'tool:search_replace', 'tool:MultiEdit')"; /// All file operation contexts. pub const FILE_OP_CONTEXTS: &[&str] = &[ @@ -57,10 +57,12 @@ pub const FILE_OP_CONTEXTS: &[&str] = &[ "tool:multi_replace_file_content", "tool:StrReplace", "tool:create", + "tool:search_replace", + "tool:MultiEdit", ]; /// 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:run_terminal_command')"; /// Parsed filter values — multiple values per key (OR semantics). pub type FilterMap = HashMap>; diff --git a/src/delivery.rs b/src/delivery.rs index 35a03afb..00cf74ab 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -364,6 +364,18 @@ pub(crate) fn gate_block_detail(reason: &str) -> &'static str { } } +/// Short PTY sentinel for Grok idle wake — no angle brackets (WT paste hazard). +/// +/// Real message bodies are **not** pasted into the composer. They are delivered +/// from native `grok-stop` via `hookSpecificOutput.additionalContext` after a +/// genuine end-of-turn Stop (observe-only hooks discard stdout). +pub(crate) const GROK_WAKE_TRIGGER: &str = "hcom: wake"; + +/// PTY inject text for Grok: always the short wake sentinel. +pub(crate) fn build_grok_inject_text(_db: &HcomDb, _recipient: &str) -> String { + GROK_WAKE_TRIGGER.to_string() +} + /// Build PTY wake text for tools whose delivery path is not human-visible. /// /// Claude and Codex inject the plain `` trigger because their hooks already @@ -1002,6 +1014,30 @@ pub(crate) fn inject_text(port: u16, text: &str) -> bool { } } +/// How long to wait after a Grok full-body inject before force-Enter. +/// +/// Scales with payload size: short pings stay snappy; large review dumps under +/// slow WSL/GB get up to ~4s so the composer can finish accepting paste. +/// After force-Enter, how long to wait for a *real* submit signal before +/// re-sending Enter. Must not treat `commit_delivery_ack`'s ST_ACTIVE as success +/// (that was a false positive that skipped retries while the composer still +/// held unsent text). +const GROK_SUBMIT_CONFIRM: Duration = Duration::from_millis(1200); + +/// True when Grok has actually started a user turn (UPS / stop cycle), not when +/// we merely acked the bus. `deliver:*` is our own premature-ack context and +/// must NOT count as submit. +fn grok_turn_started(status: &str, context: &str) -> bool { + if status != ST_ACTIVE && status != "active" { + // Allow any non-listening non-active that clearly means mid-turn tools. + // Primary success path is ST_ACTIVE + prompt/trigger from UPS. + return false; + } + matches!(context, "prompt" | "trigger") + || context.starts_with("tool:") + || context.starts_with("approved:") +} + /// Inject Enter key to PTY via TCP pub(crate) fn inject_enter(port: u16) -> bool { match TcpStream::connect(format!("127.0.0.1:{}", port)) { @@ -1464,13 +1500,19 @@ pub fn run_delivery_loop( let cols = state.screen.read().map(|s| s.cols).unwrap_or(80); let input_box_width = (cols as usize).saturating_sub(15).max(10); let text = match parsed_tool { + // Grok: short wake sentinel only; body via Stop additionalContext. + Some(Tool::Grok) => build_grok_inject_text(db, ¤t_name), Some(Tool::Claude) | Some(Tool::Codex) | Some(Tool::Cursor) | Some(Tool::Kimi) | Some(Tool::Copilot) | Some(Tool::Pi) | Some(Tool::Omp) => "".to_string(), _ => build_wake_inject_text(db, ¤t_name, input_box_width), }; - if inject_text(state.inject_port, &text) { + // No Ctrl-U clear for Grok: cannot observe composer; risk + // partial draft deletion. Sentinel is short and idle-gated. + let inject_ok = inject_text(state.inject_port, &text); + + if inject_ok { log_info( "native", "delivery.injected", @@ -1478,7 +1520,7 @@ pub fn run_delivery_loop( "Injected '{}' (len={}, inject_attempt={})", truncate_chars(&text, 40), text.len(), - inject_attempt + inject_attempt, ), ); injected_text = text; @@ -1651,6 +1693,133 @@ pub fn run_delivery_loop( State::WaitTextRender => { let elapsed = phase_started_at.elapsed(); + // Grok: unscrapeable composer → cannot prove ownership. + // Protocol (owner review): PTY only submits a short wake + // sentinel; real body is delivered from grok-stop via + // hookSpecificOutput.additionalContext. Never force-ack. + if config.tool == "grok" { + if enter_attempt > 0 { + let still_pending = db.has_pending(¤t_name); + let turn_started = match db.get_status(¤t_name) { + Ok(Some((status, ctx))) => grok_turn_started(&status, &ctx), + _ => false, + }; + // Success for PTY wake: turn started (UPS trigger/prompt) + // or Stop already acked (pending cleared). Do NOT ack here. + if turn_started || !still_pending { + log_info( + "native", + "delivery.grok_wake_done", + &format!( + "Grok wake complete (enter_attempt={enter_attempt}, turn_started={turn_started}, pending={still_pending})" + ), + ); + inject_attempt = 0; + attempt = 0; + // If still pending, Stop will deliver body. Stay idle + // until notify after Stop; then Pending wakes again. + delivery_state = State::Idle; + phase_started_at = Instant::now(); + continue; + } + + if elapsed < GROK_SUBMIT_CONFIRM { + std::thread::sleep(Duration::from_millis(50)); + continue; + } + + if enter_attempt < MAX_ENTER_ATTEMPTS { + let user_active = state.is_user_active(); + let approval = + state.screen.read().map(|s| s.approval).unwrap_or(false); + if user_active || approval { + if elapsed > PHASE1_TIMEOUT { + log_warn( + "native", + "delivery.grok_enter_retry_blocked", + &format!( + "Grok Enter retry blocked (user_active={user_active}, approval={approval}); pending kept" + ), + ); + delivery_state = State::Pending; + inject_attempt += 1; + attempt += 1; + } else { + std::thread::sleep(Duration::from_millis(50)); + } + continue; + } + log_info( + "native", + "delivery.grok_retry_enter", + &format!( + "Grok still pending after {:?}; re-Enter sentinel (attempt={}/{})", + elapsed, + enter_attempt + 1, + MAX_ENTER_ATTEMPTS, + ), + ); + // Single Enter only — double Enter can queue two wakes. + inject_enter(state.inject_port); + enter_attempt += 1; + phase_started_at = Instant::now(); + continue; + } + + log_warn( + "native", + "delivery.grok_wake_unconfirmed", + &format!( + "Grok wake unconfirmed after {MAX_ENTER_ATTEMPTS} Enters; leaving pending (no force-ack)" + ), + ); + // Leave messages pending for a later idle cycle. + delivery_state = State::Pending; + inject_attempt += 1; + attempt += 1; + phase_started_at = Instant::now(); + continue; + } + + // First Enter after short settle for tiny sentinel. + let settle = Duration::from_millis(400); + if elapsed < settle { + std::thread::sleep(Duration::from_millis(25)); + continue; + } + let user_active = state.is_user_active(); + let approval = state.screen.read().map(|s| s.approval).unwrap_or(false); + if user_active || approval { + if elapsed > PHASE1_TIMEOUT { + log_warn( + "native", + "delivery.grok_enter_blocked", + &format!( + "Grok force-Enter blocked (user_active={user_active}, approval={approval})" + ), + ); + delivery_state = State::Pending; + inject_attempt += 1; + attempt += 1; + } else { + std::thread::sleep(Duration::from_millis(50)); + } + continue; + } + log_info( + "native", + "delivery.grok_force_enter", + &format!( + "Forcing Enter after wake sentinel (bytes={}) — body via Stop additionalContext", + injected_text.len(), + ), + ); + inject_enter(state.inject_port); + enter_attempt = 1; + phase_started_at = Instant::now(); + continue; + } + // Inspect the latest screen before applying the deadline. This // avoids rejecting a render that completed at the timeout edge. let screen = state.screen.read().unwrap(); @@ -2156,6 +2325,24 @@ mod tests { // ---- phase-1 ownership tests ---- + #[test] + fn grok_turn_started_ignores_premature_deliver_ack() { + // commit_delivery_ack sets active + deliver:sender — must NOT count. + assert!(!grok_turn_started(ST_ACTIVE, "deliver:vomu")); + assert!(!grok_turn_started(ST_LISTENING, "")); + // Real UPS contexts. + assert!(grok_turn_started(ST_ACTIVE, "prompt")); + assert!(grok_turn_started(ST_ACTIVE, "trigger")); + assert!(grok_turn_started(ST_ACTIVE, "tool:Bash")); + } + + #[test] + fn grok_wake_trigger_has_no_angle_brackets() { + assert!(!GROK_WAKE_TRIGGER.contains('<')); + assert!(!GROK_WAKE_TRIGGER.contains('>')); + assert_eq!(GROK_WAKE_TRIGGER, "hcom: wake"); + } + #[test] fn phase1_timeout_is_ten_seconds() { assert_eq!(PHASE1_TIMEOUT, Duration::from_secs(10)); diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index 1cebccdb..6644c863 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -379,10 +379,7 @@ fn route_claude_hook( (code, stdout, None) } HOOK_POST => handle_posttooluse(db, ctx, payload, instance_name, &instance_data, &updates), - HOOK_POLL => { - let (code, stdout) = handle_poll(db, ctx, instance_name, &instance_data); - (code, stdout, None) - } + HOOK_POLL => handle_poll(db, ctx, instance_name, &instance_data), HOOK_NOTIFY => { let (code, stdout) = handle_notify(db, payload, instance_name, &updates); (code, stdout, None) @@ -1027,6 +1024,11 @@ fn inject_bootstrap_if_needed( /// Check for unread messages to deliver at PostToolUse. fn get_posttooluse_messages(db: &HcomDb, instance_name: &str) -> Option<(Value, DeliveryAck)> { + // Grok: PostToolUse is observe-only; native grok-stop owns delivery. + if common::is_grok_host() { + return None; + } + let prepared = common::prepare_pending_messages(db, instance_name)?; let model_context = common::format_messages_json_for_instance(db, &prepared.messages, instance_name); @@ -1096,21 +1098,26 @@ fn handle_poll( ctx: &HcomContext, instance_name: &str, instance_data: &InstanceRow, -) -> (i32, String) { +) -> (i32, String, Option) { log::log_info( "hooks", "stop.enter", &format!( - "instance={} is_headless={} pty_mode={}", - instance_name, ctx.is_background, ctx.is_pty_mode + "instance={} is_headless={} pty_mode={} grok_host={}", + instance_name, + ctx.is_background, + ctx.is_pty_mode, + common::is_grok_host() ), ); - // PTY mode: exit immediately, PTY wrapper handles injection - if ctx.is_pty_mode { + // Grok loads Claude-compat Stop hooks, but native `grok-stop` is the sole + // delivery owner (StopHookJson.additionalContext). Claude-compat only + // flips listening + notifies — no followup_message (not in Grok's schema). + if common::is_grok_host() || ctx.is_pty_mode { lifecycle::set_status(db, instance_name, ST_LISTENING, "", Default::default()); common::notify_hook_instance_with_db(db, instance_name); - return (0, String::new()); + return (0, String::new(), None); } // Non-PTY: poll for messages @@ -1138,7 +1145,8 @@ fn handle_poll( let stdout = output .map(|v| serde_json::to_string(&v).unwrap_or_default()) .unwrap_or_default(); - (exit_code, stdout) + // poll_messages already commits the delivery ack before returning. + (exit_code, stdout, None) } /// Parent UserPromptSubmit: fallback bootstrap, PTY mode message delivery. @@ -1174,7 +1182,14 @@ fn handle_userpromptsubmit( return (0, serde_json::to_string(&output).unwrap_or_default(), None); } - // PTY mode: deliver messages + // Grok host: UPS is observe-only (stdout discarded). Status is handled by + // native grok-userpromptsubmit; leave pending for grok-stop additionalContext. + if common::is_grok_host() { + lifecycle::set_status(db, instance_name, ST_ACTIVE, "prompt", Default::default()); + return (0, String::new(), None); + } + + // PTY mode: deliver pending via Claude-compatible UPS payload. if ctx.is_pty_mode && let Some(prepared) = common::prepare_pending_messages(db, instance_name) { diff --git a/src/hooks/common.rs b/src/hooks/common.rs index fb521806..531114fb 100644 --- a/src/hooks/common.rs +++ b/src/hooks/common.rs @@ -70,6 +70,96 @@ pub(crate) const SAFE_HCOM_COMMANDS: &[&str] = &[ "--new-terminal", ]; +/// True when this hook process was spawned by Grok Build. +/// +/// Grok injects `GROK_SESSION_ID` / `GROK_HOOK_EVENT` / `GROK_HOOK_NAME` on every +/// hook. It also loads Claude-compat hooks from `~/.claude/settings.json`, so +/// Claude handlers often run under Grok and must not use Claude's exit-2 Stop +/// delivery (Grok treats Stop as passive and ignores `decision: block`). +pub fn is_grok_host() -> bool { + std::env::var_os("GROK_SESSION_ID").is_some() + || std::env::var_os("GROK_HOOK_EVENT").is_some() + || std::env::var_os("GROK_HOOK_NAME").is_some() +} + +/// True when the submitted prompt already carries an hcom full-body inject. +/// +/// PTY delivery for Grok types the message into the composer and hits Enter. +/// UserPromptSubmit then fires (often twice: Claude-compat + native Grok hooks). +/// Emitting `followup_message` again queues a **duplicate** turn. Detect the +/// already-submitted body so hooks only ack (or no-op) instead of re-queueing. +pub fn prompt_already_carries_hcom_body(prompt: &str, formatted: &str) -> bool { + let prompt = prompt.trim(); + if prompt.is_empty() || formatted.is_empty() { + return false; + } + // Full-body inject is sanitized (`<>` → `[]`); compare against both forms. + let key: String = formatted.chars().filter(|c| *c >= ' ').take(48).collect(); + let key = key.trim(); + if key.len() >= 12 && prompt.contains(key) { + return true; + } + let sanitized: String = formatted + .chars() + .map(|c| match c { + '<' => '[', + '>' => ']', + c => c, + }) + .take(48) + .collect(); + let sanitized = sanitized.trim(); + if sanitized.len() >= 12 && prompt.contains(sanitized) { + return true; + } + // Heuristic for our inject format: "[request #123] from → to: …" + prompt.contains("request #") && (prompt.contains('→') || prompt.contains("->")) +} + +#[cfg(test)] +mod grok_host_tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn is_grok_host_false_without_env() { + // Clear any residual vars from other tests in this process. + unsafe { + std::env::remove_var("GROK_SESSION_ID"); + std::env::remove_var("GROK_HOOK_EVENT"); + std::env::remove_var("GROK_HOOK_NAME"); + } + assert!(!is_grok_host()); + } + + #[test] + #[serial] + fn is_grok_host_true_with_session_id() { + unsafe { + std::env::set_var("GROK_SESSION_ID", "sess-1"); + std::env::remove_var("GROK_HOOK_EVENT"); + std::env::remove_var("GROK_HOOK_NAME"); + } + assert!(is_grok_host()); + unsafe { + std::env::remove_var("GROK_SESSION_ID"); + } + } + + #[test] + fn prompt_already_carries_detects_inject_format() { + let body = "[request #42] test → nami: hello world please reply"; + assert!(prompt_already_carries_hcom_body(body, body)); + assert!(prompt_already_carries_hcom_body( + &format!("prefix {body} suffix"), + body + )); + assert!(!prompt_already_carries_hcom_body("just chatting", body)); + assert!(!prompt_already_carries_hcom_body("", body)); + } +} + /// Pre-gate check: should hooks proceed? /// /// diff --git a/src/hooks/grok.rs b/src/hooks/grok.rs new file mode 100644 index 00000000..705409de --- /dev/null +++ b/src/hooks/grok.rs @@ -0,0 +1,871 @@ +//! Grok Build (xAI `grok` CLI) native hook handlers and `$GROK_HOME`/`~/.grok` hooks. +//! +//! Observe-only events (SessionStart, UserPromptSubmit, PostToolUse) discard +//! stdout on Grok — never deliver bus messages there. Stop is a real gate: +//! deliver only via `hookSpecificOutput.additionalContext` on genuine end-of-turn. +//! + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::db::{HcomDb, InstanceRow}; +use crate::hooks::{DeliveryAck, HookPayload, common}; +use crate::instance_binding; +use crate::instance_lifecycle as lifecycle; +use crate::instances; +use crate::log; +use crate::paths; +use crate::shared::context::HcomContext; +use crate::shared::{ST_ACTIVE, ST_LISTENING}; + +const HCOM_TRIGGER: &str = ""; +const HOOK_TIMEOUT_SECS: u64 = 15; + +/// (Grok event name, hcom subcommand suffix) +const GROK_HOOK_COMMANDS: &[(&str, &str)] = &[ + ("SessionStart", "grok-sessionstart"), + ("UserPromptSubmit", "grok-userpromptsubmit"), + ("PreToolUse", "grok-pretooluse"), + ("PostToolUse", "grok-posttooluse"), + ("Stop", "grok-stop"), + ("SessionEnd", "grok-sessionend"), +]; + +#[derive(Debug, thiserror::Error)] +pub enum SetupError { + #[error("existing Grok config at {} could not be read: {source}", path.display())] + ExistingReadFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("existing Grok config at {} is not valid JSON: {source}", path.display())] + ExistingParseFailed { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("existing Grok config at {} must be a JSON object", path.display())] + ExistingRootNotObject { path: PathBuf }, + #[error("failed to create Grok config directory {}: {source}", path.display())] + DirCreateFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("JSON serialization failed: {0}")] + SerializationFailed(#[from] serde_json::Error), + #[error("atomic write to {} failed: {source}", path.display())] + AtomicWriteFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("post-write Grok hook verification failed for {}", .0.display())] + PostWriteVerifyFailed(PathBuf), +} + +/// Resolve Grok config root: `$GROK_HOME` if set, else `/.grok`. +fn grok_config_dir() -> PathBuf { + if let Ok(home) = std::env::var("GROK_HOME") { + let trimmed = home.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + crate::runtime_env::tool_config_root().join(".grok") +} + +fn default_grok_config_dir() -> PathBuf { + if let Ok(home) = std::env::var("GROK_HOME") { + let trimmed = home.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + dirs::home_dir().unwrap_or_default().join(".grok") +} + +pub fn get_grok_hooks_path() -> PathBuf { + grok_config_dir().join("hooks").join("hcom.json") +} + +fn build_grok_hook_command(command: &str) -> String { + let mut parts = crate::runtime_env::get_hcom_prefix(); + parts.push(command.to_string()); + parts.join(" ") +} + +fn is_hcom_grok_command(command: &str) -> bool { + let trimmed = command.trim(); + GROK_HOOK_COMMANDS + .iter() + .any(|(_, suffix)| trimmed == build_grok_hook_command(suffix)) +} + +fn expected_command_hook(command: &str) -> Value { + json!({ + "type": "command", + "command": build_grok_hook_command(command), + "timeout": HOOK_TIMEOUT_SECS, + }) +} + +/// Grok native format: +/// ```json +/// { "hooks": { "SessionStart": [ { "hooks": [ { "type":"command", "command":"..." } ] } ] } } +/// ``` +fn merge_hcom_hooks(root: &mut Value) { + if !root.is_object() { + *root = json!({}); + } + let obj = root.as_object_mut().unwrap(); + let hooks = obj.entry("hooks".to_string()).or_insert_with(|| json!({})); + if !hooks.is_object() { + *hooks = json!({}); + } + let hooks = hooks.as_object_mut().unwrap(); + + for (event, command) in GROK_HOOK_COMMANDS { + let groups = hooks + .entry((*event).to_string()) + .or_insert_with(|| json!([])); + if !groups.is_array() { + *groups = json!([]); + } + let groups = groups.as_array_mut().unwrap(); + + // Drop any matcher group that only contained our hcom commands, and + // strip hcom commands from mixed groups. + groups.retain_mut(|group| { + let Some(group_obj) = group.as_object_mut() else { + return true; + }; + let Some(entries) = group_obj.get_mut("hooks").and_then(Value::as_array_mut) else { + return true; + }; + entries.retain(|entry| { + !entry + .get("command") + .and_then(Value::as_str) + .is_some_and(is_hcom_grok_command) + }); + !entries.is_empty() || group_obj.keys().any(|k| k != "hooks" && k != "matcher") + }); + + groups.push(json!({ + "hooks": [expected_command_hook(command)] + })); + } +} + +fn remove_hcom_hooks(root: &mut Value) { + let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else { + return; + }; + for groups in hooks.values_mut() { + let Some(groups) = groups.as_array_mut() else { + continue; + }; + groups.retain_mut(|group| { + let Some(group_obj) = group.as_object_mut() else { + return true; + }; + let Some(entries) = group_obj.get_mut("hooks").and_then(Value::as_array_mut) else { + return true; + }; + entries.retain(|entry| { + !entry + .get("command") + .and_then(Value::as_str) + .is_some_and(is_hcom_grok_command) + }); + !entries.is_empty() + }); + } + hooks.retain(|_, groups| groups.as_array().is_some_and(|groups| !groups.is_empty())); +} + +fn read_json_object(path: &Path) -> Result, SetupError> { + if !path.exists() { + return Ok(serde_json::Map::new()); + } + let content = + std::fs::read_to_string(path).map_err(|source| SetupError::ExistingReadFailed { + path: path.to_path_buf(), + source, + })?; + let value = serde_json::from_str::(&content).map_err(|source| { + SetupError::ExistingParseFailed { + path: path.to_path_buf(), + source, + } + })?; + value + .as_object() + .cloned() + .ok_or_else(|| SetupError::ExistingRootNotObject { + path: path.to_path_buf(), + }) +} + +fn write_json(path: &Path, value: &Value) -> Result<(), SetupError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|source| SetupError::DirCreateFailed { + path: parent.to_path_buf(), + source, + })?; + } + let content = serde_json::to_string_pretty(value)?; + paths::atomic_write_io(path, &content).map_err(|source| SetupError::AtomicWriteFailed { + path: path.to_path_buf(), + source, + }) +} + +fn verify_hooks_at(path: &Path) -> bool { + let Ok(content) = std::fs::read_to_string(path) else { + return false; + }; + let Ok(root) = serde_json::from_str::(&content) else { + return false; + }; + let Some(hooks) = root.get("hooks").and_then(Value::as_object) else { + return false; + }; + GROK_HOOK_COMMANDS.iter().all(|(event, command)| { + let expected = build_grok_hook_command(command); + hooks + .get(*event) + .and_then(Value::as_array) + .is_some_and(|groups| { + groups.iter().any(|group| { + group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry.get("command").and_then(Value::as_str) + == Some(expected.as_str()) + && entry.get("type").and_then(Value::as_str) == Some("command") + }) + }) + }) + }) + }) +} + +fn remove_grok_hooks_at(path: &Path) -> bool { + if !path.exists() { + return true; + } + match read_json_object(path) { + Ok(root) => { + let mut value = Value::Object(root); + remove_hcom_hooks(&mut value); + // Drop empty file content cleanup: keep {} if everything removed + if value + .get("hooks") + .and_then(Value::as_object) + .is_none_or(|h| h.is_empty()) + { + // Leave an empty hooks object rather than delete foreign files. + value = json!({ "hooks": {} }); + } + write_json(path, &value).is_ok() + } + Err(_) => false, + } +} + +fn push_unique(paths: &mut Vec, path: PathBuf) { + if path.is_absolute() && !paths.contains(&path) { + paths.push(path); + } +} + +fn grok_hooks_cleanup_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(home) = dirs::home_dir() { + push_unique( + &mut paths, + home.join(".grok").join("hooks").join("hcom.json"), + ); + } + push_unique(&mut paths, get_grok_hooks_path()); + // Also clean a default-home path when tool_config_root is isolated. + push_unique( + &mut paths, + default_grok_config_dir().join("hooks").join("hcom.json"), + ); + paths +} + +pub fn try_setup_grok_hooks(_include_permissions: bool) -> Result<(), SetupError> { + let hooks_path = get_grok_hooks_path(); + let mut hooks = Value::Object(read_json_object(&hooks_path)?); + merge_hcom_hooks(&mut hooks); + write_json(&hooks_path, &hooks)?; + if !verify_hooks_at(&hooks_path) { + return Err(SetupError::PostWriteVerifyFailed(hooks_path)); + } + // Grok has no separate CLI permissions file analogous to Cursor's + // cli-config.json; auto_approve is handled at the hcom layer / user flags. + Ok(()) +} + +pub fn verify_grok_hooks_installed(_check_permissions: bool) -> bool { + verify_hooks_at(&get_grok_hooks_path()) +} + +pub fn remove_grok_hooks() -> bool { + grok_hooks_cleanup_paths() + .iter() + .all(|path| remove_grok_hooks_at(path)) +} + +// ── Runtime handlers ──────────────────────────────────────────────────── + +fn resolve_session_id(payload: &HookPayload) -> Option { + payload + .session_id + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| { + std::env::var("GROK_SESSION_ID") + .ok() + .filter(|s| !s.is_empty()) + }) +} + +fn resolve_instance(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> Option { + instance_binding::resolve_instance_from_binding( + db, + resolve_session_id(payload).as_deref(), + ctx.process_id.as_deref(), + ) +} + +fn update_position(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload, instance_name: &str) { + let mut updates = serde_json::Map::new(); + if let Some(session_id) = resolve_session_id(payload) { + updates.insert("session_id".into(), Value::String(session_id)); + } + if let Some(path) = payload.transcript_path.as_ref().filter(|s| !s.is_empty()) { + updates.insert("transcript_path".into(), Value::String(path.clone())); + } + let cwd = payload + .raw + .get("cwd") + .and_then(Value::as_str) + .or_else(|| payload.raw.get("workspaceRoot").and_then(Value::as_str)) + .or_else(|| payload.raw.get("workspace_root").and_then(Value::as_str)) + .unwrap_or_else(|| ctx.cwd.to_str().unwrap_or("")); + if !cwd.is_empty() { + updates.insert("directory".into(), Value::String(cwd.to_string())); + } + instances::update_instance_position(db, instance_name, &updates); +} + +fn grok_session_env(ctx: &HcomContext) -> Value { + const KEYS: &[&str] = &[ + "HCOM_PROCESS_ID", + "HCOM_INSTANCE_NAME", + "HCOM_TOOL", + "HCOM_DIR", + "HCOM_LAUNCHED", + "HCOM_PTY_MODE", + "HCOM_BACKGROUND", + "HCOM_LAUNCHED_BY", + "HCOM_LAUNCH_BATCH_ID", + "HCOM_LAUNCH_EVENT_ID", + ]; + Value::Object( + KEYS.iter() + .filter_map(|key| { + ctx.raw_env + .get(*key) + .map(|value| ((*key).to_string(), Value::String(value.clone()))) + }) + .collect(), + ) +} + +fn resolved_instance(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> Option { + let instance = resolve_instance(db, ctx, payload)?; + update_position(db, ctx, payload, &instance.name); + Some(instance) +} + +fn handle_sessionstart(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> Value { + let Some(session_id) = resolve_session_id(payload) else { + return json!({ "env": grok_session_env(ctx) }); + }; + let instance_name = ctx + .process_id + .as_deref() + .and_then(|pid| instance_binding::bind_session_to_process(db, &session_id, Some(pid))) + .or_else(|| resolve_instance(db, ctx, payload).map(|instance| instance.name)); + let Some(instance_name) = instance_name else { + return json!({ "env": grok_session_env(ctx) }); + }; + let _ = db.rebind_instance_session(&instance_name, &session_id); + instance_binding::capture_and_store_launch_context(db, &instance_name); + update_position(db, ctx, payload, &instance_name); + lifecycle::set_status( + db, + &instance_name, + ST_LISTENING, + "start", + Default::default(), + ); + crate::runtime_env::set_terminal_title(&instance_name); + crate::relay::worker::ensure_worker(true); + common::notify_hook_instance_with_db(db, &instance_name); + // SessionStart is observe-only on Grok: stdout is not parsed into the model. + // Bootstrap must use launch-time channels (--rules / skill), not hook stdout. + json!({ "env": grok_session_env(ctx) }) +} + +fn handle_userpromptsubmit( + db: &HcomDb, + ctx: &HcomContext, + payload: &HookPayload, +) -> (Value, Option) { + let Some(instance) = resolved_instance(db, ctx, payload) else { + return (json!({}), None); + }; + let prompt = payload + .raw + .get("prompt") + .and_then(Value::as_str) + .or_else(|| payload.raw.get("userPrompt").and_then(Value::as_str)) + .unwrap_or(""); + let context = if prompt.trim() == HCOM_TRIGGER + || prompt.trim().eq_ignore_ascii_case("hcom: wake") + || prompt.contains("[hcom") + || prompt.contains("hcom ") + { + "trigger" + } else { + "prompt" + }; + lifecycle::set_status(db, &instance.name, ST_ACTIVE, context, Default::default()); + // UserPromptSubmit is observe-only on Grok — stdout is discarded. Delivery + // is Stop(end_turn) → hookSpecificOutput.additionalContext only. + (json!({}), None) +} + +fn handle_pretooluse(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> Value { + if let Some(instance) = resolved_instance(db, ctx, payload) { + common::update_tool_status( + db, + &instance.name, + "grok", + &payload.tool_name, + &payload.tool_input, + ); + } + // PreToolUse is blocking on Grok; always allow. + json!({ "decision": "allow" }) +} + +fn handle_posttooluse( + db: &HcomDb, + ctx: &HcomContext, + payload: &HookPayload, +) -> (Value, Option) { + let Some(_instance) = resolved_instance(db, ctx, payload) else { + return (json!({}), None); + }; + // PostToolUse is observe-only on Grok — stdout is discarded. Keep pending + // until genuine Stop(end_turn). + (json!({}), None) +} + +/// Stop reasons that mean the session/channel is gone — never deliver or ack. +fn is_session_end_stop(payload: &HookPayload) -> bool { + let reason = payload + .raw + .get("reason") + .and_then(Value::as_str) + .or_else(|| payload.raw.get("stop_reason").and_then(Value::as_str)) + .or_else(|| payload.raw.get("stopReason").and_then(Value::as_str)) + .unwrap_or("") + .to_ascii_lowercase(); + matches!( + reason.as_str(), + "channel_closed" + | "shutdown" + | "session_end" + | "sessionend" + | "end_session" + | "user_exit" + | "exit" + | "closed" + | "abort" + ) +} + +fn is_cancellable_stop_status(payload: &HookPayload) -> bool { + let status = payload + .raw + .get("status") + .and_then(Value::as_str) + .unwrap_or(""); + matches!( + status, + "cancelled" | "canceled" | "error" | "failed" | "aborted" + ) +} + +/// True when this Stop is a normal end-of-turn that may continue with context. +fn is_genuine_end_turn_stop(payload: &HookPayload) -> bool { + if is_session_end_stop(payload) || is_cancellable_stop_status(payload) { + return false; + } + let reason = payload + .raw + .get("reason") + .and_then(Value::as_str) + .or_else(|| payload.raw.get("stop_reason").and_then(Value::as_str)) + .or_else(|| payload.raw.get("stopReason").and_then(Value::as_str)) + .unwrap_or("") + .to_ascii_lowercase(); + // Empty / end_turn / completed: allow delivery. Unknown reasons: allow only + // when not session-end-like (handled above). + reason.is_empty() + || reason == "end_turn" + || reason == "endturn" + || reason == "completed" + || reason == "stop" +} + +fn handle_stop( + db: &HcomDb, + ctx: &HcomContext, + payload: &HookPayload, +) -> (Value, Option) { + let Some(instance) = resolved_instance(db, ctx, payload) else { + return (json!({}), None); + }; + lifecycle::set_status(db, &instance.name, ST_LISTENING, "", Default::default()); + common::notify_hook_instance_with_db(db, &instance.name); + + if is_session_end_stop(payload) { + log::log_info( + "hooks", + "grok.stop.session_end_skip", + &format!( + "instance={} — no deliver/ack on session-end Stop", + instance.name + ), + ); + return (json!({}), None); + } + if is_cancellable_stop_status(payload) { + return (json!({}), None); + } + if !is_genuine_end_turn_stop(payload) { + return (json!({}), None); + } + + // Stop is a real gate. Grok parses StopHookJson and feeds + // hookSpecificOutput.additionalContext back into the model. Do NOT use + // followup_message (not in the schema — silently dropped). + match common::prepare_pending_messages(db, &instance.name) { + Some(prepared) => { + log::log_info( + "hooks", + "grok.stop.additional_context", + &format!( + "instance={} bytes={}", + instance.name, + prepared.formatted.len() + ), + ); + ( + json!({ + "hookSpecificOutput": { + "additionalContext": prepared.formatted, + "additional_context": prepared.formatted, + } + }), + Some(prepared.ack), + ) + } + None => (json!({}), None), + } +} + +fn handle_sessionend(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> Value { + if let Some(instance) = resolved_instance(db, ctx, payload) { + let reason = payload + .raw + .get("reason") + .and_then(Value::as_str) + .unwrap_or("unknown"); + common::finalize_session(db, &instance.name, reason, None); + } + json!({}) +} + +/// Dispatch one Grok JSON-on-stdin hook. +pub fn dispatch_grok_hook(hook_name: &str) -> i32 { + let raw: Value = match serde_json::from_reader(std::io::stdin().lock()) { + Ok(value) => value, + Err(err) => { + log::log_warn( + "hooks", + "grok.parse_error", + &format!("hook={hook_name} err={err}"), + ); + return 0; + } + }; + let db = match HcomDb::open() { + Ok(db) => db, + Err(err) => { + log::log_warn( + "hooks", + "grok.db_error", + &format!("hook={hook_name} err={err}"), + ); + return 0; + } + }; + let ctx = HcomContext::from_os(); + if !common::hook_gate_check(&ctx, &db) { + return 0; + } + let payload = HookPayload::from_grok(hook_name, raw); + let (output, delivery_ack) = common::dispatch_with_panic_guard( + "grok", + hook_name, + (json!({ "decision": "allow" }), None), + || match hook_name { + "grok-sessionstart" => (handle_sessionstart(&db, &ctx, &payload), None), + "grok-userpromptsubmit" => handle_userpromptsubmit(&db, &ctx, &payload), + "grok-pretooluse" => (handle_pretooluse(&db, &ctx, &payload), None), + "grok-posttooluse" => handle_posttooluse(&db, &ctx, &payload), + "grok-stop" => handle_stop(&db, &ctx, &payload), + "grok-sessionend" => (handle_sessionend(&db, &ctx, &payload), None), + _ => (json!({}), None), + }, + ); + let mut stdout = std::io::stdout().lock(); + if serde_json::to_writer(&mut stdout, &output).is_ok() + && stdout.flush().is_ok() + && let Some(ack) = delivery_ack.as_ref() + { + common::commit_delivery_ack(&db, ack); + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::test_helpers::EnvGuard; + use serial_test::serial; + + fn grok_test_env() -> (tempfile::TempDir, PathBuf, EnvGuard) { + let guard = EnvGuard::new(); + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path().join("workspace"); + let home = dir.path().join("home"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + unsafe { + std::env::set_var("HOME", &home); + std::env::set_var("HCOM_DIR", workspace.join(".hcom")); + } + (dir, workspace, guard) + } + + #[test] + #[serial] + fn setup_is_idempotent_and_preserves_existing_hooks() { + let (_dir, workspace, _guard) = grok_test_env(); + let hooks_path = workspace.join(".grok/hooks/hcom.json"); + std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + std::fs::write( + &hooks_path, + serde_json::to_string_pretty(&json!({ + "hooks": { + "SessionStart": [{ + "hooks": [{ "type": "command", "command": "./custom-start.sh" }] + }] + } + })) + .unwrap(), + ) + .unwrap(); + + try_setup_grok_hooks(false).unwrap(); + let first = std::fs::read_to_string(&hooks_path).unwrap(); + try_setup_grok_hooks(false).unwrap(); + let second = std::fs::read_to_string(&hooks_path).unwrap(); + + assert_eq!(first, second); + assert!(verify_grok_hooks_installed(false)); + let root: Value = serde_json::from_str(&second).unwrap(); + let session_start = root["hooks"]["SessionStart"].as_array().unwrap(); + assert!(session_start.iter().any(|group| { + group["hooks"] + .as_array() + .unwrap() + .iter() + .any(|hook| hook["command"] == "./custom-start.sh") + })); + assert!(session_start.iter().any(|group| { + group["hooks"] + .as_array() + .unwrap() + .iter() + .any(|hook| hook["command"] == build_grok_hook_command("grok-sessionstart")) + })); + } + + #[test] + #[serial] + fn setup_replaces_stale_hcom_commands() { + let (_dir, workspace, _guard) = grok_test_env(); + let hooks_path = workspace.join(".grok/hooks/hcom.json"); + std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + std::fs::write( + &hooks_path, + serde_json::to_string_pretty(&json!({ + "hooks": { + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "hcom grok-stop" }, + { "type": "command", "command": "uvx hcom grok-stop" }, + { "type": "command", "command": "./custom-stop.sh" } + ] + } + ] + } + })) + .unwrap(), + ) + .unwrap(); + + try_setup_grok_hooks(false).unwrap(); + + let root: Value = + serde_json::from_str(&std::fs::read_to_string(hooks_path).unwrap()).unwrap(); + let stop = root["hooks"]["Stop"].as_array().unwrap(); + let all_commands: Vec<&str> = stop + .iter() + .flat_map(|g| g["hooks"].as_array().unwrap()) + .filter_map(|h| h["command"].as_str()) + .collect(); + assert_eq!( + all_commands + .iter() + .filter(|c| is_hcom_grok_command(c)) + .count(), + 1 + ); + assert!(all_commands.contains(&"./custom-stop.sh")); + } + + #[test] + #[serial] + fn remove_preserves_unrelated_hooks() { + let (_dir, workspace, _guard) = grok_test_env(); + let hooks_path = workspace.join(".grok/hooks/hcom.json"); + std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + std::fs::write( + &hooks_path, + serde_json::to_string_pretty(&json!({ + "hooks": { + "SessionEnd": [{ + "hooks": [{ "type": "command", "command": "./custom-end.sh" }] + }] + } + })) + .unwrap(), + ) + .unwrap(); + + try_setup_grok_hooks(false).unwrap(); + assert!(remove_grok_hooks()); + + let root: Value = + serde_json::from_str(&std::fs::read_to_string(hooks_path).unwrap()).unwrap(); + assert_eq!( + root["hooks"]["SessionEnd"], + json!([{ + "hooks": [{ "type": "command", "command": "./custom-end.sh" }] + }]) + ); + assert!( + root["hooks"] + .as_object() + .unwrap() + .get("SessionStart") + .is_none() + ); + } + + #[test] + fn payload_from_grok_reads_camel_case() { + let raw = json!({ + "sessionId": "sess-abc", + "hookEventName": "pre_tool_use", + "toolName": "run_terminal_command", + "toolInput": { "command": "ls" }, + "cwd": "/tmp/proj", + "workspaceRoot": "/tmp/proj" + }); + let payload = HookPayload::from_grok("grok-pretooluse", raw); + assert_eq!(payload.session_id.as_deref(), Some("sess-abc")); + assert_eq!(payload.tool, "grok"); + assert_eq!(payload.tool_name, "run_terminal_command"); + assert_eq!(payload.tool_input["command"], "ls"); + assert_eq!(payload.hook_name, "grok-pretooluse"); + } + + #[test] + fn payload_from_grok_reads_snake_case() { + let raw = json!({ + "session_id": "sess-xyz", + "tool_name": "search_replace", + "tool_input": { "file_path": "a.rs" } + }); + let payload = HookPayload::from_grok("grok-posttooluse", raw); + assert_eq!(payload.session_id.as_deref(), Some("sess-xyz")); + assert_eq!(payload.tool_name, "search_replace"); + } + + #[test] + fn session_end_stop_reasons_are_detected() { + let payload = HookPayload::from_grok("grok-stop", json!({ "reason": "channel_closed" })); + assert!(is_session_end_stop(&payload)); + let payload = HookPayload::from_grok("grok-stop", json!({ "reason": "end_turn" })); + assert!(!is_session_end_stop(&payload)); + assert!(is_genuine_end_turn_stop(&payload)); + } + + #[test] + fn stop_output_uses_additional_context_not_followup() { + // Schema smoke: Stop payload shape for Grok StopHookJson. + let body = "hello from bus"; + let out = json!({ + "hookSpecificOutput": { + "additionalContext": body, + "additional_context": body, + } + }); + assert!(out.get("followup_message").is_none()); + assert_eq!( + out["hookSpecificOutput"]["additionalContext"].as_str(), + Some(body) + ); + } +} diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index cac9b4b6..e948f5ff 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -1,4 +1,4 @@ -//! Shared hook infrastructure for all tools (Claude, Gemini, Codex, OpenCode, Kilo, Pi, Oh My Pi, Antigravity, Cursor, Kimi, Copilot). +//! Shared hook infrastructure for all tools (Claude, Gemini, Codex, OpenCode, Kilo, Pi, Oh My Pi, Antigravity, Cursor, Kimi, Copilot, Grok). pub mod antigravity; pub mod claude; @@ -9,6 +9,7 @@ pub mod copilot; pub mod cursor; pub mod family; pub mod gemini; +pub mod grok; pub mod kimi; pub mod opencode; pub mod pi; @@ -213,7 +214,7 @@ pub struct HookPayload { pub transcript_path: Option, /// Hook name (e.g., "Stop", "PostToolUse", "PreToolUse"). pub hook_name: String, - /// Tool type string ("claude", "gemini", "codex", "opencode", "kilo", "pi", "omp", "antigravity", "cursor", "kimi", "copilot"). + /// Tool type string ("claude", "gemini", "codex", "opencode", "kilo", "pi", "omp", "antigravity", "cursor", "kimi", "copilot", "grok"). pub tool: String, /// Tool name from hook (e.g., "Bash", "Write" for PostToolUse). pub tool_name: String, @@ -432,6 +433,51 @@ impl HookPayload { } } + /// Build from Grok Build native hook JSON. + /// + /// Grok stdin uses camelCase (`sessionId`, `toolName`, `toolInput`) and also + /// sets `GROK_SESSION_ID` / `GROK_HOOK_EVENT` env vars. Accept both cases. + pub fn from_grok(hook_type: &str, raw: Value) -> Self { + let tool_result = raw + .get("tool_result") + .or_else(|| raw.get("toolResult")) + .or_else(|| raw.get("tool_response")) + .or_else(|| raw.get("toolResponse")) + .map(|v| { + v.as_str() + .map(ToString::to_string) + .unwrap_or_else(|| v.to_string()) + }) + .unwrap_or_default(); + + Self { + session_id: Self::opt_str_field(&raw, &["session_id", "sessionId"]), + transcript_path: Self::opt_str_field( + &raw, + &[ + "transcript_path", + "transcriptPath", + "session_path", + "sessionPath", + ], + ), + hook_name: if hook_type.is_empty() { + Self::str_field(&raw, &["hook_event_name", "hookEventName"]) + } else { + hook_type.to_string() + }, + tool: "grok".to_string(), + tool_name: Self::str_field(&raw, &["tool_name", "toolName"]), + tool_input: Self::obj_field(&raw, &["tool_input", "toolInput"]), + tool_result, + notification_type: Self::opt_str_field( + &raw, + &["notification_type", "notificationType"], + ), + raw, + } + } + /// Build from GitHub Copilot CLI native hook JSON. /// /// PascalCase hook names yield mostly snake_case payloads. `Notification` diff --git a/src/integration_spec.rs b/src/integration_spec.rs index 52639897..10d3154f 100644 --- a/src/integration_spec.rs +++ b/src/integration_spec.rs @@ -311,6 +311,15 @@ const COPILOT_HOOKS: &[&str] = &[ "copilot-sessionend", ]; +const GROK_HOOKS: &[&str] = &[ + "grok-sessionstart", + "grok-userpromptsubmit", + "grok-pretooluse", + "grok-posttooluse", + "grok-stop", + "grok-sessionend", +]; + // ── Help examples / extra-env tables ──────────────────────────────────── const CLAUDE_HELP_EXAMPLES: &[HelpEntry] = &[ @@ -391,6 +400,16 @@ const KIMI_HELP_EXAMPLES: &[HelpEntry] = &[ ("hcom kimi --yolo", "Bypass permission prompts"), ]; +const GROK_HELP_EXAMPLES: &[HelpEntry] = &[ + ("hcom grok --model grok-build", "Use a specific model"), + ("hcom grok --always-approve", "Auto-approve tool executions"), + // -p/--single is one-shot scripting; not a persistent hcom-managed agent. + ( + "hcom grok -p \"task\"", + "One-shot headless run (exits; not for multi-turn hcom)", + ), +]; + const COPILOT_HELP_EXAMPLES: &[HelpEntry] = &[ ( "hcom copilot --model claude-haiku-4.5", @@ -1056,6 +1075,64 @@ pub static COPILOT: IntegrationSpec = IntegrationSpec { }, }; +pub static GROK: IntegrationSpec = IntegrationSpec { + tool: Tool::Grok, + name: "grok", + label: "Grok Build", + aliases: &["grok-build"], + cli_binary: "grok", + tui_prefix: "grk ", + adhoc_icon: None, + released: true, + // Grok TUI chrome is themeable; empty ready_pattern relies on prompt-empty + // + hook idle status (same approach as Cursor MVP). + ready_pattern: b"", + pty: PtySpec { + delivery_start_timeout_secs: 10, + }, + // Session id is instance-specific and would corrupt a same-tool child launch. + instance_state_env: &["GROK_SESSION_ID"], + hooks: HooksSpec { + names: GROK_HOOKS, + shared_hooks_with: None, + invocation: HookInvocation::JsonStdin, + }, + gates: GatesSpec { + require_idle: true, + // Grok TUI has no stable ready footer we can scrape yet. + require_ready_prompt: false, + // get_input_box_text("grok") is None today; requiring prompt-empty would + // permanently report prompt_has_text and block PTY inject forever. + require_prompt_empty: false, + block_on_user_activity: true, + block_on_approval: true, + // Launch readiness falls back to settle-timeout without a ready pattern. + launch_requires_ready: false, + launch_ready_on_plugin_bind: false, + }, + launch: LaunchSpec { + args_env: Some("HCOM_GROK_ARGS"), + config_dir_env: Some("GROK_HOME"), + initial_prompt: InitialPromptShape::Positional, + uses_pty_default: true, + max_launch_count: 10, + background: BackgroundMode::HeadlessPty, + }, + resume: Some(ResumeSpec { + resume: ResumeArgs::Flag("--resume"), + fork: Some(ForkArgs::AppendFlag("--fork-session")), + }), + help: HelpSpec { + unique_examples: GROK_HELP_EXAMPLES, + extra_env: &[], + }, + status_detail: StatusDetailSpec { + bash: &["run_terminal_command", "Bash"], + file: &["search_replace", "write", "Edit", "Write", "MultiEdit"], + delegate: &["spawn_subagent", "Task"], + }, +}; + pub static ADHOC: IntegrationSpec = IntegrationSpec { tool: Tool::Adhoc, name: "adhoc", @@ -1120,6 +1197,7 @@ pub static ALL: &[&IntegrationSpec] = &[ &CURSOR, &KIMI, &COPILOT, + &GROK, &ADHOC, ]; @@ -1138,6 +1216,7 @@ impl Tool { Tool::Cursor => &CURSOR, Tool::Kimi => &KIMI, Tool::Copilot => &COPILOT, + Tool::Grok => &GROK, Tool::Adhoc => &ADHOC, } } @@ -1185,6 +1264,7 @@ mod tests { Tool::Cursor, Tool::Kimi, Tool::Copilot, + Tool::Grok, Tool::Pi, Tool::Omp, Tool::Adhoc, @@ -1252,8 +1332,9 @@ mod tests { assert!(names.contains(&"cursor")); assert!(names.contains(&"kimi")); assert!(names.contains(&"copilot")); + assert!(names.contains(&"grok")); assert!(names.contains(&"omp")); - assert_eq!(names.len(), 11); + assert_eq!(names.len(), 12); } #[test] diff --git a/src/launcher.rs b/src/launcher.rs index 45a94b6d..d225ba79 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -44,6 +44,7 @@ pub enum LaunchTool { Cursor, Kimi, Copilot, + Grok, Omp, } @@ -63,6 +64,7 @@ impl LaunchTool { "cursor" | "cursor-agent" => Ok(LaunchTool::Cursor), "kimi" => Ok(LaunchTool::Kimi), "copilot" => Ok(LaunchTool::Copilot), + "grok" | "grok-build" => Ok(LaunchTool::Grok), _ => bail!("Unknown tool: {}", s), } } @@ -81,6 +83,7 @@ impl LaunchTool { LaunchTool::Cursor => "cursor", LaunchTool::Kimi => "kimi", LaunchTool::Copilot => "copilot", + LaunchTool::Grok => "grok", } } @@ -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::Grok => crate::tool::Tool::Grok, } } @@ -170,7 +174,8 @@ impl LaunchBackend { | LaunchTool::Antigravity | LaunchTool::Cursor | LaunchTool::Kimi - | LaunchTool::Copilot => LaunchBackend::HeadlessPty, + | LaunchTool::Copilot + | LaunchTool::Grok => LaunchBackend::HeadlessPty, } } } @@ -406,6 +411,7 @@ 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::Grok => ".grok", crate::tool::Tool::OpenCode | crate::tool::Tool::Adhoc => return None, }; Some(root.join(dirname)) @@ -673,6 +679,23 @@ fn ensure_hooks_installed(tool: &LaunchTool, include_permissions: bool) -> Resul } Ok(()) } + LaunchTool::Grok => { + if crate::hooks::grok::verify_grok_hooks_installed(include_permissions) { + return Ok(()); + } + if let Err(e) = crate::hooks::grok::try_setup_grok_hooks(include_permissions) { + let diag = install_diag_context( + tool, + &[("hooks_path", crate::hooks::grok::get_grok_hooks_path())], + ); + bail!( + "Failed to setup Grok hooks: {e}\n\ + Run: hcom hooks add grok\n\ + {diag}" + ); + } + Ok(()) + } } } @@ -2246,6 +2269,33 @@ pub fn launch(db: &HcomDb, mut params: LaunchParams) -> Result { inside_ai_tool, ) } + LaunchTool::Grok => { + 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: "grok", + 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, + ) + } } })(); @@ -2352,6 +2402,9 @@ pub(crate) fn validate_tool_args(tool: &LaunchTool, args: &[String]) -> Vec crate::tools::copilot_preprocessing::validate_copilot_args(args), + // Grok accepts -p/--single, --resume, --fork-session, positional prompts; + // no hcom-specific rejections yet. + LaunchTool::Grok => Vec::new(), } } @@ -2462,6 +2515,11 @@ mod tests { LaunchTool::from_str("copilot").unwrap(), LaunchTool::Copilot ); + assert_eq!(LaunchTool::from_str("grok").unwrap(), LaunchTool::Grok); + assert_eq!( + LaunchTool::from_str("grok-build").unwrap(), + LaunchTool::Grok + ); assert!(LaunchTool::from_str("unknown").is_err()); } diff --git a/src/pty/screen.rs b/src/pty/screen.rs index 51dd230c..a08bf5b1 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -497,6 +497,9 @@ 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(), + // Grok: prompt-empty gate uses generic scrape when available; no + // tool-specific VT100 input parser yet. + Ok(Tool::Grok) => None, Ok(Tool::Adhoc) => None, Err(_) => None, } diff --git a/src/router.rs b/src/router.rs index f042afa3..2d66799e 100644 --- a/src/router.rs +++ b/src/router.rs @@ -104,6 +104,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::Grok => (crate::hooks::grok::dispatch_grok_hook(hook), String::new()), Tool::Adhoc => unreachable!("adhoc has no hooks"), } } diff --git a/src/shared/tool_detection.rs b/src/shared/tool_detection.rs index 2e8e939f..be5e3a84 100644 --- a/src/shared/tool_detection.rs +++ b/src/shared/tool_detection.rs @@ -93,6 +93,16 @@ const KIMI_NATIVE: &[EnvPredicate] = &[ condition: EnvMatch::Set, }, ]; +const GROK_NATIVE: &[EnvPredicate] = &[ + EnvPredicate { + var: "GROK_SESSION_ID", + condition: EnvMatch::Set, + }, + EnvPredicate { + var: "GROK_HOOK_EVENT", + condition: EnvMatch::Set, + }, +]; const PI_NATIVE: &[EnvPredicate] = &[EnvPredicate { var: "HCOM_PI", condition: EnvMatch::Equals("1"), @@ -120,6 +130,7 @@ hcom_tool_predicate!("kilo", HCOM_TOOL_KILO); hcom_tool_predicate!("cursor", HCOM_TOOL_CURSOR); hcom_tool_predicate!("kimi", HCOM_TOOL_KIMI); hcom_tool_predicate!("copilot", HCOM_TOOL_COPILOT); +hcom_tool_predicate!("grok", HCOM_TOOL_GROK); hcom_tool_predicate!("pi", HCOM_TOOL_PI); hcom_tool_predicate!("omp", HCOM_TOOL_OMP); @@ -171,6 +182,11 @@ pub static TOOL_DETECTION_RULES: &[ToolDetectionRule] = &[ predicates: KIMI_NATIVE, clear_for_child: &["KIMI_CODE_CLI", "KIMI_SESSION_ID"], }, + ToolDetectionRule { + tool: Tool::Grok, + predicates: GROK_NATIVE, + clear_for_child: &["GROK_SESSION_ID", "GROK_HOOK_EVENT", "GROK_HOOK_NAME"], + }, ToolDetectionRule { tool: Tool::Pi, predicates: PI_NATIVE, @@ -226,6 +242,11 @@ pub static TOOL_DETECTION_RULES: &[ToolDetectionRule] = &[ predicates: HCOM_TOOL_COPILOT, clear_for_child: &["HCOM_TOOL"], }, + ToolDetectionRule { + tool: Tool::Grok, + predicates: HCOM_TOOL_GROK, + clear_for_child: &["HCOM_TOOL"], + }, ToolDetectionRule { tool: Tool::Pi, predicates: HCOM_TOOL_PI, diff --git a/src/tool.rs b/src/tool.rs index 01b8d6d4..80f41d22 100644 --- a/src/tool.rs +++ b/src/tool.rs @@ -20,6 +20,7 @@ pub enum Tool { Cursor, Kimi, Copilot, + Grok, Pi, Omp, Adhoc, @@ -103,6 +104,7 @@ impl Tool { Tool::Copilot => { crate::hooks::copilot::verify_copilot_hooks_installed(include_permissions) } + Tool::Grok => crate::hooks::grok::verify_grok_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 +141,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::Grok => crate::hooks::grok::try_setup_grok_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 +175,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::Grok => Ok(crate::hooks::grok::remove_grok_hooks()), Tool::Pi => crate::hooks::pi::remove_pi_plugin() .map(|_| true) .map_err(|e| e.to_string()), @@ -194,6 +199,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::Grok => crate::hooks::grok::get_grok_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(), diff --git a/src/transcript/grok.rs b/src/transcript/grok.rs new file mode 100644 index 00000000..c0ae1126 --- /dev/null +++ b/src/transcript/grok.rs @@ -0,0 +1,296 @@ +//! Grok Build transcript parser (`updates.jsonl`). +//! +//! Grok persists ACP-style session update lines under +//! `~/.grok/sessions///updates.jsonl`. +//! Each line is a JSON-RPC-ish envelope: +//! +//! ```jsonc +//! {"method":"session/update","params":{"update":{ +//! "sessionUpdate":"user_message_chunk", +//! "content":{"type":"text","text":"…"} +//! }}} +//! ``` +//! +//! We rebuild exchanges from user/agent message chunks and tool_call events. + +use std::path::Path; + +use serde_json::Value; + +use super::shared::{ + Exchange, ToolUse, finalize_action_text, normalize_tool_name, read_file_lossy, truncate_str, +}; + +fn update_kind(update: &Value) -> &str { + update + .get("sessionUpdate") + .or_else(|| update.get("session_update")) + .and_then(Value::as_str) + .unwrap_or("") +} + +fn content_text(content: &Value) -> String { + // Do not trim individual stream chunks — Grok's ACP concatenates raw text; + // trimming `"Hello "` + `"world"` would become `"Helloworld"`. + match content { + Value::String(s) => s.to_string(), + Value::Object(obj) => obj + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + Value::Array(blocks) => { + let mut parts = Vec::new(); + for block in blocks { + if let Some(t) = block.get("text").and_then(Value::as_str) + && !t.is_empty() + { + parts.push(t.to_string()); + } + } + parts.join("") + } + _ => String::new(), + } +} + +fn tool_from_call(update: &Value) -> Option { + let name = update + .get("title") + .or_else(|| update.get("toolName")) + .or_else(|| update.get("name")) + .and_then(Value::as_str) + .unwrap_or("tool"); + let input = update + .get("rawInput") + .or_else(|| update.get("input")) + .cloned() + .unwrap_or(Value::Object(Default::default())); + let file = input + .get("path") + .or_else(|| input.get("file_path")) + .or_else(|| input.get("target_file")) + .and_then(Value::as_str) + .map(|p| { + Path::new(p) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(p) + .to_string() + }); + let command = input + .get("command") + .and_then(Value::as_str) + .map(|s| truncate_str(s, 200).to_string()); + let is_error = update + .get("isError") + .or_else(|| update.get("is_error")) + .and_then(Value::as_bool) + .or_else(|| { + update + .get("status") + .and_then(Value::as_str) + .map(|s| matches!(s, "failed" | "error" | "cancelled" | "canceled")) + }) + .unwrap_or(false); + Some(ToolUse { + name: normalize_tool_name(name).to_string(), + is_error, + file, + command, + }) +} + +/// Parse a Grok Build `updates.jsonl` transcript into shared exchanges. +pub(crate) fn parse_grok_updates_jsonl( + path: &Path, + last: usize, + _detailed: bool, +) -> Result, String> { + let content = read_file_lossy(path)?; + + let mut exchanges: Vec = Vec::new(); + let mut current_user = String::new(); + let mut current_action = String::new(); + let mut current_tools: Vec = Vec::new(); + let mut current_files: Vec = Vec::new(); + let mut assistant_chunks: Vec = Vec::new(); + let mut position = 0usize; + let mut in_exchange = false; + let mut timestamp = String::new(); + + let flush = |exchanges: &mut Vec, + position: &mut usize, + current_user: &mut String, + current_action: &mut String, + current_tools: &mut Vec, + current_files: &mut Vec, + assistant_chunks: &mut Vec, + in_exchange: &mut bool, + timestamp: &str| { + if !*in_exchange + && current_user.is_empty() + && assistant_chunks.is_empty() + && current_tools.is_empty() + { + return; + } + *position += 1; + let tools = std::mem::take(current_tools); + let action = if !assistant_chunks.is_empty() { + assistant_chunks.join("") + } else { + finalize_action_text(current_action, &tools, &[], false) + }; + let mut files = std::mem::take(current_files); + files.sort(); + files.dedup(); + exchanges.push(Exchange { + position: *position, + user: std::mem::take(current_user), + action, + files, + timestamp: timestamp.to_string(), + tools, + edits: Vec::new(), + errors: Vec::new(), + ended_on_error: false, + }); + current_action.clear(); + assistant_chunks.clear(); + *in_exchange = false; + }; + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let Ok(root) = serde_json::from_str::(line) else { + continue; + }; + if let Some(ts) = root.get("timestamp").and_then(|v| { + v.as_str() + .map(|s| s.to_string()) + .or_else(|| v.as_i64().map(|n| n.to_string())) + }) { + timestamp = ts; + } + let update = root + .pointer("/params/update") + .or_else(|| root.get("update")) + .cloned() + .unwrap_or(Value::Null); + if update.is_null() { + continue; + } + match update_kind(&update) { + "user_message_chunk" => { + if in_exchange && (!current_user.is_empty() || !assistant_chunks.is_empty()) { + flush( + &mut exchanges, + &mut position, + &mut current_user, + &mut current_action, + &mut current_tools, + &mut current_files, + &mut assistant_chunks, + &mut in_exchange, + ×tamp, + ); + } + let text = content_text(update.get("content").unwrap_or(&Value::Null)); + if !text.is_empty() { + if !current_user.is_empty() { + current_user.push('\n'); + } + current_user.push_str(&text); + in_exchange = true; + } + } + "agent_message_chunk" => { + let text = content_text(update.get("content").unwrap_or(&Value::Null)); + if !text.is_empty() { + assistant_chunks.push(text); + in_exchange = true; + } + } + "tool_call" => { + if let Some(tool) = tool_from_call(&update) { + if let Some(ref f) = tool.file { + current_files.push(f.clone()); + } + if current_action.is_empty() { + current_action = tool.name.clone(); + } + current_tools.push(tool); + in_exchange = true; + } + } + "turn_completed" | "agent_end_turn" => { + flush( + &mut exchanges, + &mut position, + &mut current_user, + &mut current_action, + &mut current_tools, + &mut current_files, + &mut assistant_chunks, + &mut in_exchange, + ×tamp, + ); + } + _ => {} + } + } + + flush( + &mut exchanges, + &mut position, + &mut current_user, + &mut current_action, + &mut current_tools, + &mut current_files, + &mut assistant_chunks, + &mut in_exchange, + ×tamp, + ); + + if last > 0 && exchanges.len() > last { + Ok(exchanges.split_off(exchanges.len() - last)) + } else { + Ok(exchanges) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn parses_user_and_agent_chunks() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("updates.jsonl"); + let mut f = std::fs::File::create(&path).unwrap(); + writeln!( + f, + r#"{{"timestamp":1,"params":{{"update":{{"sessionUpdate":"user_message_chunk","content":{{"type":"text","text":"hello grok"}}}}}}}}"# + ) + .unwrap(); + writeln!( + f, + r#"{{"timestamp":2,"params":{{"update":{{"sessionUpdate":"agent_message_chunk","content":{{"type":"text","text":"hi"}}}}}}}}"# + ) + .unwrap(); + writeln!( + f, + r#"{{"timestamp":3,"params":{{"update":{{"sessionUpdate":"turn_completed"}}}}}}"# + ) + .unwrap(); + let exchanges = parse_grok_updates_jsonl(&path, 10, false).unwrap(); + assert_eq!(exchanges.len(), 1); + assert_eq!(exchanges[0].user, "hello grok"); + assert_eq!(exchanges[0].action, "hi"); + } +} diff --git a/src/transcript/mod.rs b/src/transcript/mod.rs index 460ae423..dcfb656a 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 grok; pub mod kimi; pub mod opencode; pub mod pi; @@ -40,6 +41,7 @@ pub enum TranscriptBackend { KimiWireJsonl, CopilotJsonl, PiJsonl, + GrokUpdatesJsonl, } /// Where `transcript search --all` discovers sessions for a tool. @@ -59,6 +61,7 @@ enum TranscriptDiscovery { CopilotSessionState, PiSessions, OmpSessions, + GrokSessions, } #[derive(Debug, Clone, Copy)] @@ -124,6 +127,11 @@ static TRANSCRIPT_PROFILES: &[TranscriptProfile] = &[ backend: TranscriptBackend::CopilotJsonl, discovery: TranscriptDiscovery::CopilotSessionState, }, + TranscriptProfile { + tool: Tool::Grok, + backend: TranscriptBackend::GrokUpdatesJsonl, + discovery: TranscriptDiscovery::GrokSessions, + }, ]; fn profile_for_tool(tool: Tool) -> Option<&'static TranscriptProfile> { @@ -217,6 +225,9 @@ pub fn read( copilot::parse_copilot_jsonl(path, opts.last, opts.detailed) } TranscriptBackend::PiJsonl => pi::parse_pi_jsonl(path, opts.last, opts.detailed), + TranscriptBackend::GrokUpdatesJsonl => { + grok::parse_grok_updates_jsonl(path, opts.last, opts.detailed) + } TranscriptBackend::OpenCodeSqlite => { let sid = opts.session_id.as_deref().unwrap_or(""); if sid.is_empty() { @@ -255,6 +266,10 @@ pub fn detect_tool_from_path(path: &str) -> Option { // being silently assigned a parser. if lower.contains("antigravity") || lower.contains("/agy/") || lower.contains("/agy-") { Some(Tool::Antigravity) + } else if lower.contains("/.grok/sessions/") + || (file_name == "updates.jsonl" && lower.contains("/.grok/")) + { + Some(Tool::Grok) } else if lower.contains("/agent-transcripts/") { Some(Tool::Cursor) } else if lower.contains("/.copilot/session-state/") @@ -535,6 +550,9 @@ pub fn disk_search_roots(tool: Tool) -> Vec { } TranscriptDiscovery::PiSessions => pi_session_roots(), TranscriptDiscovery::OmpSessions => omp_session_roots(), + TranscriptDiscovery::GrokSessions => { + vec![home.join(".grok").join("sessions")] + } TranscriptDiscovery::OpenCodeDatabase | TranscriptDiscovery::KiloDatabase => Vec::new(), } } diff --git a/src/tui/db.rs b/src/tui/db.rs index 644492ed..2b8b91d1 100644 --- a/src/tui/db.rs +++ b/src/tui/db.rs @@ -246,6 +246,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::Grok) => Tool::Grok, 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..94348d53 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -88,6 +88,7 @@ pub enum Tool { Cursor, Kimi, Copilot, + Grok, 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::Grok => Some(crate::tool::Tool::Grok), 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::Grok, + Self::Grok => 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::Grok, 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::Grok => 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::Grok); + assert_eq!(Tool::Grok.next(), Tool::Claude); } #[test] fn tool_prev_cycles_backward() { - assert_eq!(Tool::Claude.prev(), Tool::Copilot); + assert_eq!(Tool::Claude.prev(), Tool::Grok); + assert_eq!(Tool::Grok.prev(), Tool::Copilot); assert_eq!(Tool::Copilot.prev(), Tool::Kimi); assert_eq!(Tool::Kimi.prev(), Tool::Cursor); assert_eq!(Tool::Cursor.prev(), Tool::Antigravity);