From 332e66545ab0ba371acf7a4e09378edb61b6547f Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 12 Jul 2026 02:18:39 +0800 Subject: [PATCH 1/8] feat: add first-class Grok Build (grok) integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Grok Build as a released hcom tool so agents can launch, message, and coordinate with `hcom grok` the same way as Claude/Cursor/Kimi. What works: - `hcom grok` / `hcom grok-build` — PTY launch with hooks install - `hcom hooks add grok` / `remove` — `~/.grok/hooks/hcom.json` - `hcom r` / `hcom f` — resume + fork (`--resume`, `--fork-session`) - Full-body PTY inject delivery (Grok ignores Claude exit-2 Stop) - Claude-compat host detection so dual hooks do not double-queue - Transcript parse of `~/.grok/sessions/**/updates.jsonl` - Config: `grok_args` / `HCOM_GROK_ARGS` Delivery notes (validated end-to-end): - Grok loads Claude settings hooks and strips HCOM_PTY_MODE - Bare `` wake + WT/WSL control sequences corrupt the composer - Inject plain-text message body, single Enter, ack immediately - Skip followup_message when the prompt already carries the body Tests: cargo test green for grok hooks, integration_spec, transcript. --- README.md | 9 +- skills/hcom-agent-messaging/SKILL.md | 6 +- src/bootstrap.rs | 12 + src/commands/config.rs | 13 +- src/commands/help.rs | 25 +- src/commands/launch.rs | 3 + src/commands/resume.rs | 107 ++++ src/commands/status.rs | 1 + src/commands/transcript.rs | 4 + src/config.rs | 8 + src/core/filters.rs | 6 +- src/delivery.rs | 182 ++++++ src/hooks/claude.rs | 120 +++- src/hooks/common.rs | 90 +++ src/hooks/grok.rs | 799 +++++++++++++++++++++++++++ src/hooks/mod.rs | 50 +- src/integration_spec.rs | 79 ++- src/launcher.rs | 60 +- src/pty/screen.rs | 3 + src/router.rs | 1 + src/shared/tool_detection.rs | 21 + src/tool.rs | 6 + src/transcript/grok.rs | 285 ++++++++++ src/transcript/mod.rs | 18 + src/tui/db.rs | 1 + src/tui/model.rs | 14 +- 26 files changed, 1884 insertions(+), 39 deletions(-) create mode 100644 src/hooks/grok.rs create mode 100644 src/transcript/grok.rs 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/resume.rs b/src/commands/resume.rs index ad2e3e31..e7ab27cb 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,112 @@ 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", + ]; + const DROP_WITH_VALUE: &[&str] = &[ + "--resume", + "-r", + "--session-id", + "-s", + "--single", + "-p", + "--prompt-file", + "--prompt-json", + ]; + 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..1c8de937 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -364,6 +364,61 @@ pub(crate) fn gate_block_detail(reason: &str) -> &'static str { } } +/// Build the PTY inject payload for Grok Build. +/// +/// Grok cannot use the Claude/Cursor wake-trigger pattern (`` only): +/// - its input box is unscrapeable, so phase-1 never confirms render and hcom +/// re-injects hundreds of times; +/// - angle brackets are mis-handled in the Grok composer under WT/WSL (users +/// see Windows image paths instead of the trigger); +/// - hook `additionalContext` / `followup_message` are not reliably merged into +/// the request body when the user prompt is only a bare trigger. +/// +/// So inject the **full plain-text message body**. When Enter is forced, that +/// text *is* the user turn the model receives. +pub(crate) fn build_grok_inject_text(db: &HcomDb, recipient: &str) -> String { + let messages = db.get_unread_messages(recipient); + if messages.is_empty() { + return "hcom: wake".to_string(); + } + let values: Vec = messages + .iter() + .map(crate::hooks::common::message_to_value) + .collect(); + let body = crate::hooks::common::format_hook_messages_for_instance(db, &values, recipient); + sanitize_grok_inject_text(&body) +} + +/// Strip characters that break Grok's composer / PTY inject path. +fn sanitize_grok_inject_text(text: &str) -> String { + let cleaned: String = text + .chars() + .map(|c| match c { + // Angle brackets confuse Grok/WT (path/image paste artifacts). + '<' => '[', + '>' => ']', + c if c >= ' ' || c == '\t' => c, + // inject_text also drops non-printables; keep spaces for newlines. + '\n' | '\r' => ' ', + _ => ' ', + }) + .collect(); + let collapsed = cleaned.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + "hcom: message".to_string() + } else { + // Soft cap so we do not flood the composer; full body still arrives via + // UserPromptSubmit hooks when present. + const MAX: usize = 2000; + if collapsed.chars().count() > MAX { + let truncated: String = collapsed.chars().take(MAX).collect(); + format!("{truncated}…") + } else { + collapsed + } + } +} + /// 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 +1057,27 @@ pub(crate) fn inject_text(port: u16, text: &str) -> bool { } } +/// Inject raw bytes (including control characters) to the PTY. +fn inject_bytes(port: u16, bytes: &[u8]) -> bool { + if bytes.is_empty() { + return false; + } + match TcpStream::connect(format!("127.0.0.1:{}", port)) { + Ok(mut stream) => stream.write_all(bytes).is_ok(), + Err(_) => false, + } +} + +/// Best-effort clear of the current composer line before a Grok inject. +/// +/// Only Ctrl-U (kill line). Avoid Ctrl-A/K and CSI sequences: under Windows +/// Terminal they have been observed to paste stale clipboard / image paths +/// (`C:\Users\...\Pictures\*.png`) into the Grok composer. +fn clear_composer_best_effort(port: u16) { + let _ = inject_bytes(port, b"\x15"); // Ctrl-U + std::thread::sleep(Duration::from_millis(40)); +} + /// 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,12 +1540,18 @@ 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: full plain-text body (see build_grok_inject_text). + 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 parsed_tool == Some(Tool::Grok) { + clear_composer_best_effort(state.inject_port); + } + if inject_text(state.inject_port, &text) { log_info( "native", @@ -1651,6 +1733,93 @@ pub fn run_delivery_loop( State::WaitTextRender => { let elapsed = phase_started_at.elapsed(); + // Grok's input box is unscrapeable (`input_text` stays None), so + // exclusive-ownership phase-1 never succeeds and previously + // re-injected `` hundreds of times. After a short settle, + // force Enter and verify via pending-cursor advance instead. + if config.tool == "grok" { + const GROK_INJECT_SETTLE: Duration = Duration::from_millis(350); + if elapsed < GROK_INJECT_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 unscrapeable inject (bytes={})", + injected_text.len() + ), + ); + // Single plain Enter only. Do NOT also send Ctrl+Enter / + // CSI-u: Grok treats Enter as queue and Ctrl+Enter as + // send-now, so sending both can leave a duplicate in the + // composer queue; CSI sequences also confuse WT into + // pasting Pictures\*.png paths. + inject_enter(state.inject_port); + enter_attempt = 1; + + // Full-body inject: the composer text *is* the user turn. + // Ack immediately so UserPromptSubmit hooks (Claude + + // native Grok both fire) see no pending and do not emit + // followup_message — that was a second copy in the queue. + if let Some(prepared) = + crate::hooks::common::prepare_pending_messages(db, ¤t_name) + { + crate::hooks::common::commit_delivery_ack(db, &prepared.ack); + log_info( + "native", + "delivery.grok_acked", + &format!( + "Acked after full-body inject (last_event_id={})", + prepared.ack.last_event_id + ), + ); + } + + // Do not post-clear the composer: Ctrl sequences after + // submit have been seen to re-paste garbage under WT. + + inject_attempt = 0; + attempt = 0; + if db.has_pending(¤t_name) { + log_info( + "native", + "delivery.more_pending", + "More messages pending after Grok inject", + ); + delivery_state = State::Pending; + } else { + log_info( + "native", + "delivery.complete", + "Grok full-body inject complete, going idle", + ); + delivery_state = State::Idle; + } + 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(); @@ -2157,6 +2326,19 @@ mod tests { // ---- phase-1 ownership tests ---- #[test] + #[test] + fn grok_inject_strips_angle_brackets() { + let cleaned = sanitize_grok_inject_text("hello → world"); + assert!(!cleaned.contains('<'), "cleaned={cleaned}"); + assert!(!cleaned.contains('>'), "cleaned={cleaned}"); + assert!(cleaned.contains("hello"), "cleaned={cleaned}"); + } + + #[test] + fn grok_inject_empty_falls_back() { + assert_eq!(sanitize_grok_inject_text(" \n\t "), "hcom: message"); + } + 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..c4e3097e 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) @@ -1035,6 +1032,24 @@ fn get_posttooluse_messages(db: &HcomDb, instance_name: &str) -> Option<(Value, let user_display = common::format_hook_messages_for_instance(db, &prepared.messages, instance_name); + // Grok loads Claude-compat hooks but does not honor Claude's + // hookSpecificOutput/additionalContext on PostToolUse the same way. + // Emit followup_message (Grok binary accepts it) so mid-turn delivery works. + if common::is_grok_host() { + return Some(( + serde_json::json!({ + "followup_message": model_context, + "additional_context": model_context, + "systemMessage": user_display, + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": model_context, + }, + }), + prepared.ack, + )); + } + Some(( serde_json::json!({ "systemMessage": user_display, @@ -1096,21 +1111,31 @@ 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() ), ); + // Grok Build loads Claude-compat Stop hooks but treats Stop as passive: + // exit-2 + decision:block does NOT re-prompt. Deliver via followup_message + // (one-shot — never long-poll under Grok or Stop hangs for wait_timeout). + if common::is_grok_host() { + return handle_poll_for_grok(db, instance_name); + } + // PTY mode: exit immediately, PTY wrapper handles injection if 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 +1163,38 @@ 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) +} + +/// One-shot Stop delivery for Grok Build (Claude-compat hook path). +/// +/// Grok ignores Claude's exit-2 / `decision:block` Stop semantics. Use +/// `followup_message` (same field Cursor uses; present in the Grok binary) and +/// defer cursor advance until stdout is flushed by the dispatcher. +fn handle_poll_for_grok(db: &HcomDb, instance_name: &str) -> (i32, String, Option) { + lifecycle::set_status(db, instance_name, ST_LISTENING, "", Default::default()); + common::notify_hook_instance_with_db(db, instance_name); + + match common::prepare_pending_messages(db, instance_name) { + Some(prepared) => { + log::log_info( + "hooks", + "stop.grok_followup", + &format!( + "instance={} bytes={}", + instance_name, + prepared.formatted.len() + ), + ); + let stdout = serde_json::json!({ + "followup_message": prepared.formatted, + }) + .to_string(); + (0, stdout, Some(prepared.ack)) + } + None => (0, String::new(), None), + } } /// Parent UserPromptSubmit: fallback bootstrap, PTY mode message delivery. @@ -1174,8 +1230,9 @@ fn handle_userpromptsubmit( return (0, serde_json::to_string(&output).unwrap_or_default(), None); } - // PTY mode: deliver messages - if ctx.is_pty_mode + // PTY mode, or Grok host (Grok strips HCOM_PTY_MODE from hook env so + // is_pty_mode is false even for hcom-launched sessions): deliver pending. + if (ctx.is_pty_mode || common::is_grok_host()) && let Some(prepared) = common::prepare_pending_messages(db, instance_name) { let user_display = @@ -1183,6 +1240,47 @@ fn handle_userpromptsubmit( let model_context = common::format_messages_json_for_instance(db, &prepared.messages, instance_name); + if common::is_grok_host() { + let prompt = _payload + .raw + .get("prompt") + .and_then(|v| v.as_str()) + .or_else(|| _payload.raw.get("userPrompt").and_then(|v| v.as_str())) + .unwrap_or(""); + // PTY full-body path already put the message in the user turn. + // Emitting followup_message here queues a second copy in Grok's + // composer (user sees duplicate hcom text). + if common::prompt_already_carries_hcom_body(prompt, &model_context) + || common::prompt_already_carries_hcom_body(prompt, &user_display) + { + log::log_info( + "hooks", + "userpromptsubmit.grok_skip_followup", + &format!( + "instance={} prompt already carries body (bytes={})", + instance_name, + prompt.len() + ), + ); + // Ack only — empty object so dispatcher still flushes + commits. + return (0, "{}".to_string(), Some(prepared.ack)); + } + // Bare / empty prompt: need a followup turn with the body. + log::log_info( + "hooks", + "userpromptsubmit.grok_delivery", + &format!("instance={} bytes={}", instance_name, model_context.len()), + ); + let output = serde_json::json!({ + "followup_message": model_context, + }); + return ( + 0, + serde_json::to_string(&output).unwrap_or_default(), + Some(prepared.ack), + ); + } + let output = serde_json::json!({ "systemMessage": user_display, "hookSpecificOutput": { 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..f83cd364 --- /dev/null +++ b/src/hooks/grok.rs @@ -0,0 +1,799 @@ +//! Grok Build (xAI `grok` CLI) native hook handlers and `~/.grok/hooks` management. +//! +//! Grok loads lifecycle hooks from `~/.grok/hooks/*.json` (always trusted) using +//! the nested Claude-compatible event format. Message delivery mirrors Cursor: +//! `additional_context` on PostToolUse and `followup_message` on Stop. + +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), +} + +fn grok_config_dir() -> PathBuf { + crate::runtime_env::tool_config_root().join(".grok") +} + +fn default_grok_config_dir() -> PathBuf { + 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); + let Some(instance) = db.get_instance_full(&instance_name).ok().flatten() else { + return json!({ "env": grok_session_env(ctx) }); + }; + 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); + let mut output = serde_json::Map::from_iter([("env".into(), grok_session_env(ctx))]); + if let Some(bootstrap) = + common::inject_bootstrap_once(db, ctx, &instance_name, &instance, "grok") + { + // Emit both snake_case (Cursor-style) and camelCase (Claude-style) so + // either Grok parser path can inject bootstrap context. + output.insert( + "additional_context".into(), + Value::String(bootstrap.clone()), + ); + output.insert( + "hookSpecificOutput".into(), + json!({ + "hookEventName": "SessionStart", + "additionalContext": bootstrap, + }), + ); + } + Value::Object(output) +} + +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()); + + // Pending bus messages: only emit followup when the prompt does NOT already + // carry the PTY full-body inject. Otherwise Grok queues a duplicate turn + // (and dual Claude+native hooks would double that). + match common::prepare_pending_messages(db, &instance.name) { + Some(prepared) => { + if common::prompt_already_carries_hcom_body(prompt, &prepared.formatted) { + log::log_info( + "hooks", + "grok.userpromptsubmit.skip_followup", + &format!("instance={} prompt already carries body", instance.name), + ); + (json!({}), Some(prepared.ack)) + } else { + ( + json!({ "followup_message": prepared.formatted }), + Some(prepared.ack), + ) + } + } + None => (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); + }; + match common::prepare_pending_messages(db, &instance.name) { + Some(prepared) => ( + json!({ + "additional_context": prepared.formatted, + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": prepared.formatted, + } + }), + Some(prepared.ack), + ), + None => (json!({}), None), + } +} + +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); + + // Cursor only delivers on status=="completed". Grok may omit status or use + // different values — deliver whenever there is a pending message unless the + // turn was clearly cancelled/errored. + let status = payload + .raw + .get("status") + .and_then(Value::as_str) + .unwrap_or("completed"); + if matches!( + status, + "cancelled" | "canceled" | "error" | "failed" | "aborted" + ) { + return (json!({}), None); + } + + match common::prepare_pending_messages(db, &instance.name) { + Some(prepared) => ( + json!({ "followup_message": 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"); + } +} 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..6631c46f 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,12 @@ 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"), + ("hcom grok -p \"task\"", "Headless single-turn via --single"), +]; + const COPILOT_HELP_EXAMPLES: &[HelpEntry] = &[ ( "hcom copilot --model claude-haiku-4.5", @@ -1056,6 +1071,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: None, + 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 +1193,7 @@ pub static ALL: &[&IntegrationSpec] = &[ &CURSOR, &KIMI, &COPILOT, + &GROK, &ADHOC, ]; @@ -1138,6 +1212,7 @@ impl Tool { Tool::Cursor => &CURSOR, Tool::Kimi => &KIMI, Tool::Copilot => &COPILOT, + Tool::Grok => &GROK, Tool::Adhoc => &ADHOC, } } @@ -1185,6 +1260,7 @@ mod tests { Tool::Cursor, Tool::Kimi, Tool::Copilot, + Tool::Grok, Tool::Pi, Tool::Omp, Tool::Adhoc, @@ -1252,8 +1328,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..ec012ba7 --- /dev/null +++ b/src/transcript/grok.rs @@ -0,0 +1,285 @@ +//! 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 { + match content { + Value::String(s) => s.trim().to_string(), + Value::Object(obj) => obj + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .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) { + let trimmed = t.trim(); + if !trimmed.is_empty() { + parts.push(trimmed.to_string()); + } + } + } + parts.join("\n") + } + _ => 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()); + Some(ToolUse { + name: normalize_tool_name(name).to_string(), + is_error: false, + 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); From aabd1c64421e68fa91118bcf10f661786bc06cbb Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 12 Jul 2026 02:34:50 +0800 Subject: [PATCH 2/8] fix: unbreak clippy for grok delivery tests Remove a duplicated #[test] attribute, restore #[test] on phase1_timeout_is_ten_seconds, and drop a useless borrow that fails clippy -D warnings on CI. --- src/commands/list.rs | 2 +- src/delivery.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/delivery.rs b/src/delivery.rs index 1c8de937..a9711d1f 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -2325,7 +2325,6 @@ mod tests { // ---- phase-1 ownership tests ---- - #[test] #[test] fn grok_inject_strips_angle_brackets() { let cleaned = sanitize_grok_inject_text("hello → world"); @@ -2339,6 +2338,7 @@ mod tests { assert_eq!(sanitize_grok_inject_text(" \n\t "), "hcom: message"); } + #[test] fn phase1_timeout_is_ten_seconds() { assert_eq!(PHASE1_TIMEOUT, Duration::from_secs(10)); } From 90b0262b42bdc794783a4aa4384c424c7450c369 Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 12 Jul 2026 05:41:10 +0800 Subject: [PATCH 3/8] fix(grok): pace large PTY pastes and retry Enter on slow WSL Grok full-body inject was writing the whole body then force-Entering after a fixed 350ms and acking immediately. On slow WSL/Grok Build the composer often still had not absorbed the paste, so Enter was dropped and the body sat unsent with no retry. - Pace inject in small chunks with short gaps - Scale post-inject settle with payload length (cap 4s) - After first Enter+ack, confirm status left listening; re-Enter up to 3x --- src/delivery.rs | 194 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 168 insertions(+), 26 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index a9711d1f..23a2db2b 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -1057,6 +1057,60 @@ pub(crate) fn inject_text(port: u16, text: &str) -> bool { } } +/// Pace large Grok pastes so a slow WSL/GB TUI can absorb them. +/// +/// A single multi-KB write + immediate Enter is often lost under WSL: the +/// composer is still chewing the paste when `\r` arrives, so the user sees a +/// full body sitting unsent and hcom has already acked (no retry). Chunk + gap +/// keeps the PTY input queue digestible. +fn inject_text_paced(port: u16, text: &str) -> bool { + const CHUNK_CHARS: usize = 96; + const GAP: Duration = Duration::from_millis(35); + + let safe_text: String = text + .chars() + .filter(|c| *c >= ' ' || *c == '\t') + .collect(); + if safe_text.is_empty() { + return false; + } + + let chars: Vec = safe_text.chars().collect(); + if chars.len() <= CHUNK_CHARS { + return inject_text(port, &safe_text); + } + + let mut offset = 0; + while offset < chars.len() { + let end = (offset + CHUNK_CHARS).min(chars.len()); + let chunk: String = chars[offset..end].iter().collect(); + if !inject_text(port, &chunk) { + return false; + } + offset = end; + if offset < chars.len() { + std::thread::sleep(GAP); + } + } + true +} + +/// 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. +fn grok_inject_settle(len: usize) -> Duration { + const BASE_MS: u64 = 450; + const PER_CHAR_MS: u64 = 2; + const MAX_MS: u64 = 4000; + let scaled = BASE_MS.saturating_add((len as u64).saturating_mul(PER_CHAR_MS)); + Duration::from_millis(scaled.min(MAX_MS)) +} + +/// After force-Enter, how long to wait for Grok to leave `listening` before +/// re-sending Enter (Enter often dropped mid-paste on slow WSL). +const GROK_SUBMIT_CONFIRM: Duration = Duration::from_millis(1500); + /// Inject raw bytes (including control characters) to the PTY. fn inject_bytes(port: u16, bytes: &[u8]) -> bool { if bytes.is_empty() { @@ -1552,7 +1606,15 @@ pub fn run_delivery_loop( clear_composer_best_effort(state.inject_port); } - if inject_text(state.inject_port, &text) { + // Grok: paced inject for large bodies (WSL paste reliability). + // Other tools keep a single burst write. + let inject_ok = if parsed_tool == Some(Tool::Grok) { + inject_text_paced(state.inject_port, &text) + } else { + inject_text(state.inject_port, &text) + }; + + if inject_ok { log_info( "native", "delivery.injected", @@ -1735,11 +1797,99 @@ pub fn run_delivery_loop( // Grok's input box is unscrapeable (`input_text` stays None), so // exclusive-ownership phase-1 never succeeds and previously - // re-injected `` hundreds of times. After a short settle, - // force Enter and verify via pending-cursor advance instead. + // re-injected `` hundreds of times. After a length-scaled + // settle, force Enter; if status stays `listening` (Enter often + // dropped mid-paste under slow WSL), re-Enter a few times. if config.tool == "grok" { - const GROK_INJECT_SETTLE: Duration = Duration::from_millis(350); - if elapsed < GROK_INJECT_SETTLE { + // --- post-Enter confirm / retry path --- + if enter_attempt > 0 { + let submitted = match db.get_status(¤t_name) { + Ok(Some((status, _))) => status != ST_LISTENING, + _ => false, + }; + if submitted { + log_info( + "native", + "delivery.grok_submit_confirmed", + &format!( + "Grok left listening after Enter (enter_attempt={enter_attempt})" + ), + ); + inject_attempt = 0; + attempt = 0; + if db.has_pending(¤t_name) { + delivery_state = State::Pending; + } else { + 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 { + // Don't stampede Enter over a real user; wait. + if elapsed > PHASE1_TIMEOUT { + log_warn( + "native", + "delivery.grok_enter_retry_blocked", + &format!( + "Grok Enter retry blocked (user_active={user_active}, approval={approval})" + ), + ); + // Already acked — give up rather than re-inject. + delivery_state = State::Idle; + inject_attempt = 0; + attempt = 0; + } else { + std::thread::sleep(Duration::from_millis(50)); + } + continue; + } + log_info( + "native", + "delivery.grok_retry_enter", + &format!( + "Grok still listening after {:?}; re-Enter (attempt={}/{})", + elapsed, + enter_attempt + 1, + MAX_ENTER_ATTEMPTS + ), + ); + inject_enter(state.inject_port); + enter_attempt += 1; + phase_started_at = Instant::now(); + continue; + } + + log_warn( + "native", + "delivery.grok_submit_unconfirmed", + &format!( + "Grok still listening after {MAX_ENTER_ATTEMPTS} Enters (bytes={}); leaving idle (already acked)", + injected_text.len() + ), + ); + // Text may still sit in the composer — user can press Enter. + // We already acked to avoid dual-UPS duplicates; do not re-inject. + delivery_state = State::Idle; + inject_attempt = 0; + attempt = 0; + phase_started_at = Instant::now(); + continue; + } + + // --- first Enter: length-scaled settle --- + let settle = grok_inject_settle(injected_text.chars().count()); + if elapsed < settle { std::thread::sleep(Duration::from_millis(25)); continue; } @@ -1766,12 +1916,13 @@ pub fn run_delivery_loop( "native", "delivery.grok_force_enter", &format!( - "Forcing Enter after unscrapeable inject (bytes={})", - injected_text.len() + "Forcing Enter after unscrapeable inject (bytes={}, settle_ms={})", + injected_text.len(), + settle.as_millis() ), ); // Single plain Enter only. Do NOT also send Ctrl+Enter / - // CSI-u: Grok treats Enter as queue and Ctrl+Enter as + // CSI-u: Grok treats Enter as submit and Ctrl+Enter as // send-now, so sending both can leave a duplicate in the // composer queue; CSI sequences also confuse WT into // pasting Pictures\*.png paths. @@ -1796,26 +1947,9 @@ pub fn run_delivery_loop( ); } + // Stay in WaitTextRender to confirm submit / retry Enter. // Do not post-clear the composer: Ctrl sequences after // submit have been seen to re-paste garbage under WT. - - inject_attempt = 0; - attempt = 0; - if db.has_pending(¤t_name) { - log_info( - "native", - "delivery.more_pending", - "More messages pending after Grok inject", - ); - delivery_state = State::Pending; - } else { - log_info( - "native", - "delivery.complete", - "Grok full-body inject complete, going idle", - ); - delivery_state = State::Idle; - } phase_started_at = Instant::now(); continue; } @@ -2338,6 +2472,14 @@ mod tests { assert_eq!(sanitize_grok_inject_text(" \n\t "), "hcom: message"); } + #[test] + fn grok_inject_settle_scales_with_length() { + assert_eq!(grok_inject_settle(0), Duration::from_millis(450)); + assert_eq!(grok_inject_settle(100), Duration::from_millis(650)); + // Cap at 4s even for huge pastes. + assert_eq!(grok_inject_settle(10_000), Duration::from_millis(4000)); + } + #[test] fn phase1_timeout_is_ten_seconds() { assert_eq!(PHASE1_TIMEOUT, Duration::from_secs(10)); From 5250e178ac5e80f8a3e8f10aae5a4c7e8febf4ac Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 12 Jul 2026 05:54:11 +0800 Subject: [PATCH 4/8] fix(grok): stop false-confirming submit after premature ack commit_delivery_ack sets ST_ACTIVE immediately, which made the post-Enter confirm path believe Grok had submitted even when the body still sat in the composer (UPS only fired after a manual Enter ~25s later). Defer bus ack until real UPS (prompt|trigger) or pending is cleared by the skip-followup hook path; re-Enter while still pending; double-\r burst for slow WSL paste digest. --- src/delivery.rs | 139 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 92 insertions(+), 47 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index 23a2db2b..361eae7a 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -1107,9 +1107,25 @@ fn grok_inject_settle(len: usize) -> Duration { Duration::from_millis(scaled.min(MAX_MS)) } -/// After force-Enter, how long to wait for Grok to leave `listening` before -/// re-sending Enter (Enter often dropped mid-paste on slow WSL). -const GROK_SUBMIT_CONFIRM: Duration = Duration::from_millis(1500); +/// 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 raw bytes (including control characters) to the PTY. fn inject_bytes(port: u16, bytes: &[u8]) -> bool { @@ -1797,24 +1813,54 @@ pub fn run_delivery_loop( // Grok's input box is unscrapeable (`input_text` stays None), so // exclusive-ownership phase-1 never succeeds and previously - // re-injected `` hundreds of times. After a length-scaled - // settle, force Enter; if status stays `listening` (Enter often - // dropped mid-paste under slow WSL), re-Enter a few times. + // re-injected `` hundreds of times. + // + // Flow: + // 1) paced full-body inject + length-scaled settle + // 2) force Enter *without* bus-ack (ack sets ST_ACTIVE and + // used to false-confirm submit, killing Enter retries) + // 3) wait for real UPS (`status_context` prompt|trigger) or + // pending cleared by the UPS skip-followup ack path + // 4) re-Enter while still pending (WSL often drops first \r) if config.tool == "grok" { // --- post-Enter confirm / retry path --- if enter_attempt > 0 { - let submitted = match db.get_status(¤t_name) { - Ok(Some((status, _))) => status != ST_LISTENING, + 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, }; + // UPS skip-followup acks when prompt already carries body. + let submitted = turn_started || !still_pending; + if submitted { log_info( "native", "delivery.grok_submit_confirmed", &format!( - "Grok left listening after Enter (enter_attempt={enter_attempt})" + "Grok submit confirmed (enter_attempt={enter_attempt}, turn_started={turn_started}, pending={still_pending})" ), ); + // If UPS already acked, nothing to do. If turn started + // but pending remains (race), ack now without followup risk + // only when prompt path owns the body — UPS should win first. + if still_pending + && let Some(prepared) = + crate::hooks::common::prepare_pending_messages( + db, + ¤t_name, + ) + { + crate::hooks::common::commit_delivery_ack(db, &prepared.ack); + log_info( + "native", + "delivery.grok_acked", + &format!( + "Acked after turn start (last_event_id={})", + prepared.ack.last_event_id + ), + ); + } inject_attempt = 0; attempt = 0; if db.has_pending(¤t_name) { @@ -1836,19 +1882,18 @@ pub fn run_delivery_loop( let approval = state.screen.read().map(|s| s.approval).unwrap_or(false); if user_active || approval { - // Don't stampede Enter over a real user; wait. if elapsed > PHASE1_TIMEOUT { log_warn( "native", "delivery.grok_enter_retry_blocked", &format!( - "Grok Enter retry blocked (user_active={user_active}, approval={approval})" + "Grok Enter retry blocked (user_active={user_active}, approval={approval}); pending kept" ), ); - // Already acked — give up rather than re-inject. - delivery_state = State::Idle; - inject_attempt = 0; - attempt = 0; + // Keep pending so a later idle cycle can retry. + delivery_state = State::Pending; + inject_attempt += 1; + attempt += 1; } else { std::thread::sleep(Duration::from_millis(50)); } @@ -1858,12 +1903,17 @@ pub fn run_delivery_loop( "native", "delivery.grok_retry_enter", &format!( - "Grok still listening after {:?}; re-Enter (attempt={}/{})", + "Grok still pending after {:?}; re-Enter (attempt={}/{})", elapsed, enter_attempt + 1, MAX_ENTER_ATTEMPTS ), ); + // Plain \r only (see first Enter). Burst of two with a + // short gap: first often lands as "consumed by paste + // digest" on slow WSL, second actually submits. + inject_enter(state.inject_port); + std::thread::sleep(Duration::from_millis(120)); inject_enter(state.inject_port); enter_attempt += 1; phase_started_at = Instant::now(); @@ -1874,12 +1924,16 @@ pub fn run_delivery_loop( "native", "delivery.grok_submit_unconfirmed", &format!( - "Grok still listening after {MAX_ENTER_ATTEMPTS} Enters (bytes={}); leaving idle (already acked)", + "Grok still pending after {MAX_ENTER_ATTEMPTS} Enters (bytes={}); force-ack so bus does not loop-reinject", injected_text.len() ), ); - // Text may still sit in the composer — user can press Enter. - // We already acked to avoid dual-UPS duplicates; do not re-inject. + // Last resort: clear bus (composer may still need manual Enter). + if let Some(prepared) = + crate::hooks::common::prepare_pending_messages(db, ¤t_name) + { + crate::hooks::common::commit_delivery_ack(db, &prepared.ack); + } delivery_state = State::Idle; inject_attempt = 0; attempt = 0; @@ -1916,40 +1970,20 @@ pub fn run_delivery_loop( "native", "delivery.grok_force_enter", &format!( - "Forcing Enter after unscrapeable inject (bytes={}, settle_ms={})", + "Forcing Enter after unscrapeable inject (bytes={}, settle_ms={}) — defer ack until UPS", injected_text.len(), settle.as_millis() ), ); - // Single plain Enter only. Do NOT also send Ctrl+Enter / - // CSI-u: Grok treats Enter as submit and Ctrl+Enter as - // send-now, so sending both can leave a duplicate in the - // composer queue; CSI sequences also confuse WT into - // pasting Pictures\*.png paths. + // Plain Enter only. Do NOT ack here: commit_delivery_ack sets + // ST_ACTIVE which previously false-confirmed submit and skipped + // retries while the body sat unsent in the composer. + // UPS (claude-compat + native) acks when prompt already carries + // the full body and skips followup_message — no duplicate queue. + inject_enter(state.inject_port); + std::thread::sleep(Duration::from_millis(120)); inject_enter(state.inject_port); enter_attempt = 1; - - // Full-body inject: the composer text *is* the user turn. - // Ack immediately so UserPromptSubmit hooks (Claude + - // native Grok both fire) see no pending and do not emit - // followup_message — that was a second copy in the queue. - if let Some(prepared) = - crate::hooks::common::prepare_pending_messages(db, ¤t_name) - { - crate::hooks::common::commit_delivery_ack(db, &prepared.ack); - log_info( - "native", - "delivery.grok_acked", - &format!( - "Acked after full-body inject (last_event_id={})", - prepared.ack.last_event_id - ), - ); - } - - // Stay in WaitTextRender to confirm submit / retry Enter. - // Do not post-clear the composer: Ctrl sequences after - // submit have been seen to re-paste garbage under WT. phase_started_at = Instant::now(); continue; } @@ -2480,6 +2514,17 @@ mod tests { assert_eq!(grok_inject_settle(10_000), Duration::from_millis(4000)); } + #[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 phase1_timeout_is_ten_seconds() { assert_eq!(PHASE1_TIMEOUT, Duration::from_secs(10)); From a622db852f5be802d6c8e4b586a498ea03cefc48 Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 12 Jul 2026 15:56:48 +0800 Subject: [PATCH 5/8] feat(grok): add wake-mode delivery (bare trigger + UPS followup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HCOM_GROK_DELIVERY=wake|full (also ~/.hcom/grok_delivery_mode) selects PTY inject strategy. Wake mode pastes only "hcom: wake" then Enter so GB can queue quickly; bus body is delivered via native UPS followup_message. Claude-compat UPS defers bare-wake delivery to native grok hooks to avoid dual followup races. E2E on headless silo: inject→enter ~1s, model saw BARE-WAKE-TEST payload and replied PONG-BARE. --- src/delivery.rs | 136 ++++++++++++++++++++++++++++++++------------ src/hooks/claude.rs | 29 +++++----- 2 files changed, 113 insertions(+), 52 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index 361eae7a..b18cb89b 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -364,22 +364,50 @@ pub(crate) fn gate_block_detail(reason: &str) -> &'static str { } } -/// Build the PTY inject payload for Grok Build. +/// How Grok PTY delivery puts work into the session. /// -/// Grok cannot use the Claude/Cursor wake-trigger pattern (`` only): -/// - its input box is unscrapeable, so phase-1 never confirms render and hcom -/// re-injects hundreds of times; -/// - angle brackets are mis-handled in the Grok composer under WT/WSL (users -/// see Windows image paths instead of the trigger); -/// - hook `additionalContext` / `followup_message` are not reliably merged into -/// the request body when the user prompt is only a bare trigger. +/// Controlled by `HCOM_GROK_DELIVERY` (or `~/.hcom/grok_delivery_mode` one-line +/// file for live flips without relaunch env): +/// - `full` (default): paste full unread body into the composer, then Enter. +/// - `wake`: paste only `hcom: wake`, then Enter; body rides UPS `followup_message` +/// (CC/Codex-style). Faster first-submit on slow WSL; requires followup to work. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GrokDeliveryMode { + Full, + Wake, +} + +/// Short PTY trigger for wake mode — no angle brackets (WT image-paste hazard). +pub(crate) const GROK_WAKE_TRIGGER: &str = "hcom: wake"; + +/// Resolve Grok delivery mode. Re-read each inject so a mode file can flip live. +pub(crate) fn grok_delivery_mode() -> GrokDeliveryMode { + let from_env = std::env::var("HCOM_GROK_DELIVERY").ok(); + let from_file = std::fs::read_to_string(crate::paths::hcom_dir().join("grok_delivery_mode")) + .ok() + .map(|s| s.trim().to_string()); + let raw = from_env + .or(from_file) + .unwrap_or_default() + .to_ascii_lowercase(); + match raw.as_str() { + "wake" | "trigger" | "short" => GrokDeliveryMode::Wake, + _ => GrokDeliveryMode::Full, + } +} + +/// Build the PTY inject payload for Grok Build. /// -/// So inject the **full plain-text message body**. When Enter is forced, that -/// text *is* the user turn the model receives. +/// Default (`full`): inject the **full plain-text** message body. Grok cannot +/// use Claude's bare `` trigger as-is (unscrapeable input box, WT `<>` +/// paste bugs). Wake mode is experimental — see `GrokDeliveryMode::Wake`. pub(crate) fn build_grok_inject_text(db: &HcomDb, recipient: &str) -> String { + if grok_delivery_mode() == GrokDeliveryMode::Wake { + return GROK_WAKE_TRIGGER.to_string(); + } let messages = db.get_unread_messages(recipient); if messages.is_empty() { - return "hcom: wake".to_string(); + return GROK_WAKE_TRIGGER.to_string(); } let values: Vec = messages .iter() @@ -1100,6 +1128,10 @@ fn inject_text_paced(port: u16, text: &str) -> bool { /// 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. fn grok_inject_settle(len: usize) -> Duration { + // Short wake trigger: tiny settle only. + if len <= GROK_WAKE_TRIGGER.len() + 4 { + return Duration::from_millis(400); + } const BASE_MS: u64 = 450; const PER_CHAR_MS: u64 = 2; const MAX_MS: u64 = 4000; @@ -1609,8 +1641,13 @@ pub fn run_delivery_loop( let parsed_tool = Tool::from_str(&config.tool).ok(); 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 grok_mode = if parsed_tool == Some(Tool::Grok) { + Some(grok_delivery_mode()) + } else { + None + }; let text = match parsed_tool { - // Grok: full plain-text body (see build_grok_inject_text). + // Grok: full body or short wake — see GrokDeliveryMode. 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) @@ -1622,12 +1659,15 @@ pub fn run_delivery_loop( clear_composer_best_effort(state.inject_port); } - // Grok: paced inject for large bodies (WSL paste reliability). - // Other tools keep a single burst write. - let inject_ok = if parsed_tool == Some(Tool::Grok) { - inject_text_paced(state.inject_port, &text) - } else { - inject_text(state.inject_port, &text) + // Grok full-body: paced inject (WSL paste reliability). + // Wake mode is tiny — single burst write like CC/Codex. + let inject_ok = match grok_mode { + Some(GrokDeliveryMode::Full) => { + inject_text_paced(state.inject_port, &text) + } + Some(GrokDeliveryMode::Wake) | None => { + inject_text(state.inject_port, &text) + } }; if inject_ok { @@ -1635,10 +1675,11 @@ pub fn run_delivery_loop( "native", "delivery.injected", &format!( - "Injected '{}' (len={}, inject_attempt={})", + "Injected '{}' (len={}, inject_attempt={}, grok_mode={:?})", truncate_chars(&text, 40), text.len(), - inject_attempt + inject_attempt, + grok_mode, ), ); injected_text = text; @@ -1903,18 +1944,21 @@ pub fn run_delivery_loop( "native", "delivery.grok_retry_enter", &format!( - "Grok still pending after {:?}; re-Enter (attempt={}/{})", + "Grok still pending after {:?}; re-Enter (attempt={}/{}, mode={:?})", elapsed, enter_attempt + 1, - MAX_ENTER_ATTEMPTS + MAX_ENTER_ATTEMPTS, + grok_delivery_mode(), ), ); - // Plain \r only (see first Enter). Burst of two with a - // short gap: first often lands as "consumed by paste - // digest" on slow WSL, second actually submits. - inject_enter(state.inject_port); - std::thread::sleep(Duration::from_millis(120)); + // Full-body: double \r helps slow WSL paste digest. + // Wake mode: single \r only — double Enter can append + // two wake turns into GB's submit queue. inject_enter(state.inject_port); + if grok_delivery_mode() == GrokDeliveryMode::Full { + std::thread::sleep(Duration::from_millis(120)); + inject_enter(state.inject_port); + } enter_attempt += 1; phase_started_at = Instant::now(); continue; @@ -1966,23 +2010,25 @@ pub fn run_delivery_loop( } continue; } + let mode = grok_delivery_mode(); log_info( "native", "delivery.grok_force_enter", &format!( - "Forcing Enter after unscrapeable inject (bytes={}, settle_ms={}) — defer ack until UPS", + "Forcing Enter after unscrapeable inject (bytes={}, settle_ms={}, mode={:?}) — defer ack until UPS", injected_text.len(), - settle.as_millis() + settle.as_millis(), + mode, ), ); - // Plain Enter only. Do NOT ack here: commit_delivery_ack sets - // ST_ACTIVE which previously false-confirmed submit and skipped - // retries while the body sat unsent in the composer. - // UPS (claude-compat + native) acks when prompt already carries - // the full body and skips followup_message — no duplicate queue. - inject_enter(state.inject_port); - std::thread::sleep(Duration::from_millis(120)); + // Do NOT ack here (false-confirm via ST_ACTIVE). UPS acks when + // full-body is already in the prompt, or emits followup_message + // for bare wake so the model sees the bus payload. inject_enter(state.inject_port); + if mode == GrokDeliveryMode::Full { + std::thread::sleep(Duration::from_millis(120)); + inject_enter(state.inject_port); + } enter_attempt = 1; phase_started_at = Instant::now(); continue; @@ -2508,7 +2554,8 @@ mod tests { #[test] fn grok_inject_settle_scales_with_length() { - assert_eq!(grok_inject_settle(0), Duration::from_millis(450)); + // len 0 is treated as wake-sized (short settle). + assert_eq!(grok_inject_settle(0), Duration::from_millis(400)); assert_eq!(grok_inject_settle(100), Duration::from_millis(650)); // Cap at 4s even for huge pastes. assert_eq!(grok_inject_settle(10_000), Duration::from_millis(4000)); @@ -2525,6 +2572,21 @@ mod tests { 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 grok_inject_settle_is_short_for_wake_trigger() { + assert_eq!( + grok_inject_settle(GROK_WAKE_TRIGGER.len()), + Duration::from_millis(400) + ); + } + #[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 c4e3097e..2ab6dea9 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -1241,15 +1241,17 @@ fn handle_userpromptsubmit( common::format_messages_json_for_instance(db, &prepared.messages, instance_name); if common::is_grok_host() { + // Grok loads Claude-compat UPS *and* native grok-userpromptsubmit. + // Emitting followup from both races into two queued turns. Native + // grok owns delivery (full-body skip-ack or bare-wake followup). + // Claude-compat only acks when the PTY full-body path already put + // the payload in the user prompt (safe no-op if native acked first). let prompt = _payload .raw .get("prompt") .and_then(|v| v.as_str()) .or_else(|| _payload.raw.get("userPrompt").and_then(|v| v.as_str())) .unwrap_or(""); - // PTY full-body path already put the message in the user turn. - // Emitting followup_message here queues a second copy in Grok's - // composer (user sees duplicate hcom text). if common::prompt_already_carries_hcom_body(prompt, &model_context) || common::prompt_already_carries_hcom_body(prompt, &user_display) { @@ -1262,23 +1264,20 @@ fn handle_userpromptsubmit( prompt.len() ), ); - // Ack only — empty object so dispatcher still flushes + commits. return (0, "{}".to_string(), Some(prepared.ack)); } - // Bare / empty prompt: need a followup turn with the body. log::log_info( "hooks", - "userpromptsubmit.grok_delivery", - &format!("instance={} bytes={}", instance_name, model_context.len()), - ); - let output = serde_json::json!({ - "followup_message": model_context, - }); - return ( - 0, - serde_json::to_string(&output).unwrap_or_default(), - Some(prepared.ack), + "userpromptsubmit.grok_defer_to_native", + &format!( + "instance={} bare/wake prompt — native grok UPS owns followup", + instance_name + ), ); + // Do not prepare-ack here: leave pending for native grok UPS. + // We already called prepare_pending_messages above — that does NOT + // advance cursor; only commit_delivery_ack does. Drop ack. + return (0, "{}".to_string(), None); } let output = serde_json::json!({ From 0d83923bc9ded445fb21bb8818bc3fb6b2e5837b Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 12 Jul 2026 19:25:35 +0800 Subject: [PATCH 6/8] style: rustfmt delivery.rs for CI cargo fmt --check CI rust-tests/windows-build failed on fmt only (inject_text_paced safe_text chain). No logic change. --- src/delivery.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index b18cb89b..ae48f14b 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -1095,10 +1095,7 @@ fn inject_text_paced(port: u16, text: &str) -> bool { const CHUNK_CHARS: usize = 96; const GAP: Duration = Duration::from_millis(35); - let safe_text: String = text - .chars() - .filter(|c| *c >= ' ' || *c == '\t') - .collect(); + let safe_text: String = text.chars().filter(|c| *c >= ' ' || *c == '\t').collect(); if safe_text.is_empty() { return false; } From d482ea5faa9f608ca9aba81e421a50a3b3ec9876 Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sat, 25 Jul 2026 17:48:14 +0800 Subject: [PATCH 7/8] refactor(grok): align delivery with Grok StopHookJson protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner review (PR #81): observe-only hooks discard stdout; Stop only accepts hookSpecificOutput.additionalContext (not followup_message). - SessionStart/UPS/PostToolUse: status only — no message payloads/acks - Stop: deliver pending via additionalContext on genuine end_turn only; skip session-end reasons (channel_closed/shutdown/…) - Claude-compat: no Grok delivery ownership (native grok-stop only) - PTY: short wake sentinel only; no full-body sanitize/Ctrl-U/force-ack - Respect $GROK_HOME; resume VALUE_FLAGS for worktree/debug/system-prompt - Transcript: do not trim stream chunks; surface tool is_error - Help: -p is one-shot, not multi-turn hcom agent --- src/commands/resume.rs | 8 ++ src/delivery.rs | 311 +++++----------------------------------- src/hooks/claude.rs | 118 +++------------ src/hooks/grok.rs | 226 +++++++++++++++++++---------- src/integration_spec.rs | 8 +- src/transcript/grok.rs | 29 ++-- 6 files changed, 236 insertions(+), 464 deletions(-) diff --git a/src/commands/resume.rs b/src/commands/resume.rs index e7ab27cb..05a30faa 100644 --- a/src/commands/resume.rs +++ b/src/commands/resume.rs @@ -1074,6 +1074,13 @@ fn merge_grok_args(original: &[String], resume: &[String]) -> Vec { "--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", @@ -1085,6 +1092,7 @@ fn merge_grok_args(original: &[String], resume: &[String]) -> Vec { "--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('-'); diff --git a/src/delivery.rs b/src/delivery.rs index ae48f14b..00cf74ab 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -364,87 +364,16 @@ pub(crate) fn gate_block_detail(reason: &str) -> &'static str { } } -/// How Grok PTY delivery puts work into the session. +/// Short PTY sentinel for Grok idle wake — no angle brackets (WT paste hazard). /// -/// Controlled by `HCOM_GROK_DELIVERY` (or `~/.hcom/grok_delivery_mode` one-line -/// file for live flips without relaunch env): -/// - `full` (default): paste full unread body into the composer, then Enter. -/// - `wake`: paste only `hcom: wake`, then Enter; body rides UPS `followup_message` -/// (CC/Codex-style). Faster first-submit on slow WSL; requires followup to work. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum GrokDeliveryMode { - Full, - Wake, -} - -/// Short PTY trigger for wake mode — no angle brackets (WT image-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"; -/// Resolve Grok delivery mode. Re-read each inject so a mode file can flip live. -pub(crate) fn grok_delivery_mode() -> GrokDeliveryMode { - let from_env = std::env::var("HCOM_GROK_DELIVERY").ok(); - let from_file = std::fs::read_to_string(crate::paths::hcom_dir().join("grok_delivery_mode")) - .ok() - .map(|s| s.trim().to_string()); - let raw = from_env - .or(from_file) - .unwrap_or_default() - .to_ascii_lowercase(); - match raw.as_str() { - "wake" | "trigger" | "short" => GrokDeliveryMode::Wake, - _ => GrokDeliveryMode::Full, - } -} - -/// Build the PTY inject payload for Grok Build. -/// -/// Default (`full`): inject the **full plain-text** message body. Grok cannot -/// use Claude's bare `` trigger as-is (unscrapeable input box, WT `<>` -/// paste bugs). Wake mode is experimental — see `GrokDeliveryMode::Wake`. -pub(crate) fn build_grok_inject_text(db: &HcomDb, recipient: &str) -> String { - if grok_delivery_mode() == GrokDeliveryMode::Wake { - return GROK_WAKE_TRIGGER.to_string(); - } - let messages = db.get_unread_messages(recipient); - if messages.is_empty() { - return GROK_WAKE_TRIGGER.to_string(); - } - let values: Vec = messages - .iter() - .map(crate::hooks::common::message_to_value) - .collect(); - let body = crate::hooks::common::format_hook_messages_for_instance(db, &values, recipient); - sanitize_grok_inject_text(&body) -} - -/// Strip characters that break Grok's composer / PTY inject path. -fn sanitize_grok_inject_text(text: &str) -> String { - let cleaned: String = text - .chars() - .map(|c| match c { - // Angle brackets confuse Grok/WT (path/image paste artifacts). - '<' => '[', - '>' => ']', - c if c >= ' ' || c == '\t' => c, - // inject_text also drops non-printables; keep spaces for newlines. - '\n' | '\r' => ' ', - _ => ' ', - }) - .collect(); - let collapsed = cleaned.split_whitespace().collect::>().join(" "); - if collapsed.is_empty() { - "hcom: message".to_string() - } else { - // Soft cap so we do not flood the composer; full body still arrives via - // UserPromptSubmit hooks when present. - const MAX: usize = 2000; - if collapsed.chars().count() > MAX { - let truncated: String = collapsed.chars().take(MAX).collect(); - format!("{truncated}…") - } else { - collapsed - } - } +/// 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. @@ -1085,57 +1014,10 @@ pub(crate) fn inject_text(port: u16, text: &str) -> bool { } } -/// Pace large Grok pastes so a slow WSL/GB TUI can absorb them. -/// -/// A single multi-KB write + immediate Enter is often lost under WSL: the -/// composer is still chewing the paste when `\r` arrives, so the user sees a -/// full body sitting unsent and hcom has already acked (no retry). Chunk + gap -/// keeps the PTY input queue digestible. -fn inject_text_paced(port: u16, text: &str) -> bool { - const CHUNK_CHARS: usize = 96; - const GAP: Duration = Duration::from_millis(35); - - let safe_text: String = text.chars().filter(|c| *c >= ' ' || *c == '\t').collect(); - if safe_text.is_empty() { - return false; - } - - let chars: Vec = safe_text.chars().collect(); - if chars.len() <= CHUNK_CHARS { - return inject_text(port, &safe_text); - } - - let mut offset = 0; - while offset < chars.len() { - let end = (offset + CHUNK_CHARS).min(chars.len()); - let chunk: String = chars[offset..end].iter().collect(); - if !inject_text(port, &chunk) { - return false; - } - offset = end; - if offset < chars.len() { - std::thread::sleep(GAP); - } - } - true -} - /// 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. -fn grok_inject_settle(len: usize) -> Duration { - // Short wake trigger: tiny settle only. - if len <= GROK_WAKE_TRIGGER.len() + 4 { - return Duration::from_millis(400); - } - const BASE_MS: u64 = 450; - const PER_CHAR_MS: u64 = 2; - const MAX_MS: u64 = 4000; - let scaled = BASE_MS.saturating_add((len as u64).saturating_mul(PER_CHAR_MS)); - Duration::from_millis(scaled.min(MAX_MS)) -} - /// 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 @@ -1156,27 +1038,6 @@ fn grok_turn_started(status: &str, context: &str) -> bool { || context.starts_with("approved:") } -/// Inject raw bytes (including control characters) to the PTY. -fn inject_bytes(port: u16, bytes: &[u8]) -> bool { - if bytes.is_empty() { - return false; - } - match TcpStream::connect(format!("127.0.0.1:{}", port)) { - Ok(mut stream) => stream.write_all(bytes).is_ok(), - Err(_) => false, - } -} - -/// Best-effort clear of the current composer line before a Grok inject. -/// -/// Only Ctrl-U (kill line). Avoid Ctrl-A/K and CSI sequences: under Windows -/// Terminal they have been observed to paste stale clipboard / image paths -/// (`C:\Users\...\Pictures\*.png`) into the Grok composer. -fn clear_composer_best_effort(port: u16) { - let _ = inject_bytes(port, b"\x15"); // Ctrl-U - std::thread::sleep(Duration::from_millis(40)); -} - /// Inject Enter key to PTY via TCP pub(crate) fn inject_enter(port: u16) -> bool { match TcpStream::connect(format!("127.0.0.1:{}", port)) { @@ -1638,13 +1499,8 @@ pub fn run_delivery_loop( let parsed_tool = Tool::from_str(&config.tool).ok(); 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 grok_mode = if parsed_tool == Some(Tool::Grok) { - Some(grok_delivery_mode()) - } else { - None - }; let text = match parsed_tool { - // Grok: full body or short wake — see GrokDeliveryMode. + // 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) @@ -1652,31 +1508,19 @@ pub fn run_delivery_loop( _ => build_wake_inject_text(db, ¤t_name, input_box_width), }; - if parsed_tool == Some(Tool::Grok) { - clear_composer_best_effort(state.inject_port); - } - - // Grok full-body: paced inject (WSL paste reliability). - // Wake mode is tiny — single burst write like CC/Codex. - let inject_ok = match grok_mode { - Some(GrokDeliveryMode::Full) => { - inject_text_paced(state.inject_port, &text) - } - Some(GrokDeliveryMode::Wake) | None => { - 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", &format!( - "Injected '{}' (len={}, inject_attempt={}, grok_mode={:?})", + "Injected '{}' (len={}, inject_attempt={})", truncate_chars(&text, 40), text.len(), inject_attempt, - grok_mode, ), ); injected_text = text; @@ -1849,63 +1693,32 @@ pub fn run_delivery_loop( State::WaitTextRender => { let elapsed = phase_started_at.elapsed(); - // Grok's input box is unscrapeable (`input_text` stays None), so - // exclusive-ownership phase-1 never succeeds and previously - // re-injected `` hundreds of times. - // - // Flow: - // 1) paced full-body inject + length-scaled settle - // 2) force Enter *without* bus-ack (ack sets ST_ACTIVE and - // used to false-confirm submit, killing Enter retries) - // 3) wait for real UPS (`status_context` prompt|trigger) or - // pending cleared by the UPS skip-followup ack path - // 4) re-Enter while still pending (WSL often drops first \r) + // 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" { - // --- post-Enter confirm / retry path --- 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, }; - // UPS skip-followup acks when prompt already carries body. - let submitted = turn_started || !still_pending; - - if submitted { + // 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_submit_confirmed", + "delivery.grok_wake_done", &format!( - "Grok submit confirmed (enter_attempt={enter_attempt}, turn_started={turn_started}, pending={still_pending})" + "Grok wake complete (enter_attempt={enter_attempt}, turn_started={turn_started}, pending={still_pending})" ), ); - // If UPS already acked, nothing to do. If turn started - // but pending remains (race), ack now without followup risk - // only when prompt path owns the body — UPS should win first. - if still_pending - && let Some(prepared) = - crate::hooks::common::prepare_pending_messages( - db, - ¤t_name, - ) - { - crate::hooks::common::commit_delivery_ack(db, &prepared.ack); - log_info( - "native", - "delivery.grok_acked", - &format!( - "Acked after turn start (last_event_id={})", - prepared.ack.last_event_id - ), - ); - } inject_attempt = 0; attempt = 0; - if db.has_pending(¤t_name) { - delivery_state = State::Pending; - } else { - delivery_state = State::Idle; - } + // 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; } @@ -1928,7 +1741,6 @@ pub fn run_delivery_loop( "Grok Enter retry blocked (user_active={user_active}, approval={approval}); pending kept" ), ); - // Keep pending so a later idle cycle can retry. delivery_state = State::Pending; inject_attempt += 1; attempt += 1; @@ -1941,21 +1753,14 @@ pub fn run_delivery_loop( "native", "delivery.grok_retry_enter", &format!( - "Grok still pending after {:?}; re-Enter (attempt={}/{}, mode={:?})", + "Grok still pending after {:?}; re-Enter sentinel (attempt={}/{})", elapsed, enter_attempt + 1, MAX_ENTER_ATTEMPTS, - grok_delivery_mode(), ), ); - // Full-body: double \r helps slow WSL paste digest. - // Wake mode: single \r only — double Enter can append - // two wake turns into GB's submit queue. + // Single Enter only — double Enter can queue two wakes. inject_enter(state.inject_port); - if grok_delivery_mode() == GrokDeliveryMode::Full { - std::thread::sleep(Duration::from_millis(120)); - inject_enter(state.inject_port); - } enter_attempt += 1; phase_started_at = Instant::now(); continue; @@ -1963,27 +1768,21 @@ pub fn run_delivery_loop( log_warn( "native", - "delivery.grok_submit_unconfirmed", + "delivery.grok_wake_unconfirmed", &format!( - "Grok still pending after {MAX_ENTER_ATTEMPTS} Enters (bytes={}); force-ack so bus does not loop-reinject", - injected_text.len() + "Grok wake unconfirmed after {MAX_ENTER_ATTEMPTS} Enters; leaving pending (no force-ack)" ), ); - // Last resort: clear bus (composer may still need manual Enter). - if let Some(prepared) = - crate::hooks::common::prepare_pending_messages(db, ¤t_name) - { - crate::hooks::common::commit_delivery_ack(db, &prepared.ack); - } - delivery_state = State::Idle; - inject_attempt = 0; - attempt = 0; + // 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: length-scaled settle --- - let settle = grok_inject_settle(injected_text.chars().count()); + // 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; @@ -2007,25 +1806,15 @@ pub fn run_delivery_loop( } continue; } - let mode = grok_delivery_mode(); log_info( "native", "delivery.grok_force_enter", &format!( - "Forcing Enter after unscrapeable inject (bytes={}, settle_ms={}, mode={:?}) — defer ack until UPS", + "Forcing Enter after wake sentinel (bytes={}) — body via Stop additionalContext", injected_text.len(), - settle.as_millis(), - mode, ), ); - // Do NOT ack here (false-confirm via ST_ACTIVE). UPS acks when - // full-body is already in the prompt, or emits followup_message - // for bare wake so the model sees the bus payload. inject_enter(state.inject_port); - if mode == GrokDeliveryMode::Full { - std::thread::sleep(Duration::from_millis(120)); - inject_enter(state.inject_port); - } enter_attempt = 1; phase_started_at = Instant::now(); continue; @@ -2536,28 +2325,6 @@ mod tests { // ---- phase-1 ownership tests ---- - #[test] - fn grok_inject_strips_angle_brackets() { - let cleaned = sanitize_grok_inject_text("hello → world"); - assert!(!cleaned.contains('<'), "cleaned={cleaned}"); - assert!(!cleaned.contains('>'), "cleaned={cleaned}"); - assert!(cleaned.contains("hello"), "cleaned={cleaned}"); - } - - #[test] - fn grok_inject_empty_falls_back() { - assert_eq!(sanitize_grok_inject_text(" \n\t "), "hcom: message"); - } - - #[test] - fn grok_inject_settle_scales_with_length() { - // len 0 is treated as wake-sized (short settle). - assert_eq!(grok_inject_settle(0), Duration::from_millis(400)); - assert_eq!(grok_inject_settle(100), Duration::from_millis(650)); - // Cap at 4s even for huge pastes. - assert_eq!(grok_inject_settle(10_000), Duration::from_millis(4000)); - } - #[test] fn grok_turn_started_ignores_premature_deliver_ack() { // commit_delivery_ack sets active + deliver:sender — must NOT count. @@ -2576,14 +2343,6 @@ mod tests { assert_eq!(GROK_WAKE_TRIGGER, "hcom: wake"); } - #[test] - fn grok_inject_settle_is_short_for_wake_trigger() { - assert_eq!( - grok_inject_settle(GROK_WAKE_TRIGGER.len()), - Duration::from_millis(400) - ); - } - #[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 2ab6dea9..6644c863 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -1024,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); @@ -1032,24 +1037,6 @@ fn get_posttooluse_messages(db: &HcomDb, instance_name: &str) -> Option<(Value, let user_display = common::format_hook_messages_for_instance(db, &prepared.messages, instance_name); - // Grok loads Claude-compat hooks but does not honor Claude's - // hookSpecificOutput/additionalContext on PostToolUse the same way. - // Emit followup_message (Grok binary accepts it) so mid-turn delivery works. - if common::is_grok_host() { - return Some(( - serde_json::json!({ - "followup_message": model_context, - "additional_context": model_context, - "systemMessage": user_display, - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": model_context, - }, - }), - prepared.ack, - )); - } - Some(( serde_json::json!({ "systemMessage": user_display, @@ -1124,15 +1111,10 @@ fn handle_poll( ), ); - // Grok Build loads Claude-compat Stop hooks but treats Stop as passive: - // exit-2 + decision:block does NOT re-prompt. Deliver via followup_message - // (one-shot — never long-poll under Grok or Stop hangs for wait_timeout). - if common::is_grok_host() { - return handle_poll_for_grok(db, instance_name); - } - - // 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(), None); @@ -1167,36 +1149,6 @@ fn handle_poll( (exit_code, stdout, None) } -/// One-shot Stop delivery for Grok Build (Claude-compat hook path). -/// -/// Grok ignores Claude's exit-2 / `decision:block` Stop semantics. Use -/// `followup_message` (same field Cursor uses; present in the Grok binary) and -/// defer cursor advance until stdout is flushed by the dispatcher. -fn handle_poll_for_grok(db: &HcomDb, instance_name: &str) -> (i32, String, Option) { - lifecycle::set_status(db, instance_name, ST_LISTENING, "", Default::default()); - common::notify_hook_instance_with_db(db, instance_name); - - match common::prepare_pending_messages(db, instance_name) { - Some(prepared) => { - log::log_info( - "hooks", - "stop.grok_followup", - &format!( - "instance={} bytes={}", - instance_name, - prepared.formatted.len() - ), - ); - let stdout = serde_json::json!({ - "followup_message": prepared.formatted, - }) - .to_string(); - (0, stdout, Some(prepared.ack)) - } - None => (0, String::new(), None), - } -} - /// Parent UserPromptSubmit: fallback bootstrap, PTY mode message delivery. fn handle_userpromptsubmit( db: &HcomDb, @@ -1230,9 +1182,15 @@ fn handle_userpromptsubmit( return (0, serde_json::to_string(&output).unwrap_or_default(), None); } - // PTY mode, or Grok host (Grok strips HCOM_PTY_MODE from hook env so - // is_pty_mode is false even for hcom-launched sessions): deliver pending. - if (ctx.is_pty_mode || common::is_grok_host()) + // 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) { let user_display = @@ -1240,46 +1198,6 @@ fn handle_userpromptsubmit( let model_context = common::format_messages_json_for_instance(db, &prepared.messages, instance_name); - if common::is_grok_host() { - // Grok loads Claude-compat UPS *and* native grok-userpromptsubmit. - // Emitting followup from both races into two queued turns. Native - // grok owns delivery (full-body skip-ack or bare-wake followup). - // Claude-compat only acks when the PTY full-body path already put - // the payload in the user prompt (safe no-op if native acked first). - let prompt = _payload - .raw - .get("prompt") - .and_then(|v| v.as_str()) - .or_else(|| _payload.raw.get("userPrompt").and_then(|v| v.as_str())) - .unwrap_or(""); - if common::prompt_already_carries_hcom_body(prompt, &model_context) - || common::prompt_already_carries_hcom_body(prompt, &user_display) - { - log::log_info( - "hooks", - "userpromptsubmit.grok_skip_followup", - &format!( - "instance={} prompt already carries body (bytes={})", - instance_name, - prompt.len() - ), - ); - return (0, "{}".to_string(), Some(prepared.ack)); - } - log::log_info( - "hooks", - "userpromptsubmit.grok_defer_to_native", - &format!( - "instance={} bare/wake prompt — native grok UPS owns followup", - instance_name - ), - ); - // Do not prepare-ack here: leave pending for native grok UPS. - // We already called prepare_pending_messages above — that does NOT - // advance cursor; only commit_delivery_ack does. Drop ack. - return (0, "{}".to_string(), None); - } - let output = serde_json::json!({ "systemMessage": user_display, "hookSpecificOutput": { diff --git a/src/hooks/grok.rs b/src/hooks/grok.rs index f83cd364..705409de 100644 --- a/src/hooks/grok.rs +++ b/src/hooks/grok.rs @@ -1,8 +1,9 @@ -//! Grok Build (xAI `grok` CLI) native hook handlers and `~/.grok/hooks` management. +//! 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. //! -//! Grok loads lifecycle hooks from `~/.grok/hooks/*.json` (always trusted) using -//! the nested Claude-compatible event format. Message delivery mirrors Cursor: -//! `additional_context` on PostToolUse and `followup_message` on Stop. use std::io::Write; use std::path::{Path, PathBuf}; @@ -66,11 +67,24 @@ pub enum SetupError { 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") } @@ -399,9 +413,6 @@ fn handle_sessionstart(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> }; let _ = db.rebind_instance_session(&instance_name, &session_id); instance_binding::capture_and_store_launch_context(db, &instance_name); - let Some(instance) = db.get_instance_full(&instance_name).ok().flatten() else { - return json!({ "env": grok_session_env(ctx) }); - }; update_position(db, ctx, payload, &instance_name); lifecycle::set_status( db, @@ -413,25 +424,9 @@ fn handle_sessionstart(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> crate::runtime_env::set_terminal_title(&instance_name); crate::relay::worker::ensure_worker(true); common::notify_hook_instance_with_db(db, &instance_name); - let mut output = serde_json::Map::from_iter([("env".into(), grok_session_env(ctx))]); - if let Some(bootstrap) = - common::inject_bootstrap_once(db, ctx, &instance_name, &instance, "grok") - { - // Emit both snake_case (Cursor-style) and camelCase (Claude-style) so - // either Grok parser path can inject bootstrap context. - output.insert( - "additional_context".into(), - Value::String(bootstrap.clone()), - ); - output.insert( - "hookSpecificOutput".into(), - json!({ - "hookEventName": "SessionStart", - "additionalContext": bootstrap, - }), - ); - } - Value::Object(output) + // 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( @@ -458,28 +453,9 @@ fn handle_userpromptsubmit( "prompt" }; lifecycle::set_status(db, &instance.name, ST_ACTIVE, context, Default::default()); - - // Pending bus messages: only emit followup when the prompt does NOT already - // carry the PTY full-body inject. Otherwise Grok queues a duplicate turn - // (and dual Claude+native hooks would double that). - match common::prepare_pending_messages(db, &instance.name) { - Some(prepared) => { - if common::prompt_already_carries_hcom_body(prompt, &prepared.formatted) { - log::log_info( - "hooks", - "grok.userpromptsubmit.skip_followup", - &format!("instance={} prompt already carries body", instance.name), - ); - (json!({}), Some(prepared.ack)) - } else { - ( - json!({ "followup_message": prepared.formatted }), - Some(prepared.ack), - ) - } - } - None => (json!({}), None), - } + // 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 { @@ -501,22 +477,70 @@ fn handle_posttooluse( ctx: &HcomContext, payload: &HookPayload, ) -> (Value, Option) { - let Some(instance) = resolved_instance(db, ctx, payload) else { + let Some(_instance) = resolved_instance(db, ctx, payload) else { return (json!({}), None); }; - match common::prepare_pending_messages(db, &instance.name) { - Some(prepared) => ( - json!({ - "additional_context": prepared.formatted, - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": prepared.formatted, - } - }), - Some(prepared.ack), - ), - None => (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( @@ -530,26 +554,48 @@ fn handle_stop( lifecycle::set_status(db, &instance.name, ST_LISTENING, "", Default::default()); common::notify_hook_instance_with_db(db, &instance.name); - // Cursor only delivers on status=="completed". Grok may omit status or use - // different values — deliver whenever there is a pending message unless the - // turn was clearly cancelled/errored. - let status = payload - .raw - .get("status") - .and_then(Value::as_str) - .unwrap_or("completed"); - if matches!( - status, - "cancelled" | "canceled" | "error" | "failed" | "aborted" - ) { + 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) => ( - json!({ "followup_message": prepared.formatted }), - Some(prepared.ack), - ), + 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), } } @@ -796,4 +842,30 @@ mod tests { 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/integration_spec.rs b/src/integration_spec.rs index 6631c46f..10d3154f 100644 --- a/src/integration_spec.rs +++ b/src/integration_spec.rs @@ -403,7 +403,11 @@ const KIMI_HELP_EXAMPLES: &[HelpEntry] = &[ const GROK_HELP_EXAMPLES: &[HelpEntry] = &[ ("hcom grok --model grok-build", "Use a specific model"), ("hcom grok --always-approve", "Auto-approve tool executions"), - ("hcom grok -p \"task\"", "Headless single-turn via --single"), + // -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] = &[ @@ -1108,7 +1112,7 @@ pub static GROK: IntegrationSpec = IntegrationSpec { }, launch: LaunchSpec { args_env: Some("HCOM_GROK_ARGS"), - config_dir_env: None, + config_dir_env: Some("GROK_HOME"), initial_prompt: InitialPromptShape::Positional, uses_pty_default: true, max_launch_count: 10, diff --git a/src/transcript/grok.rs b/src/transcript/grok.rs index ec012ba7..c0ae1126 100644 --- a/src/transcript/grok.rs +++ b/src/transcript/grok.rs @@ -30,25 +30,25 @@ fn update_kind(update: &Value) -> &str { } 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.trim().to_string(), + Value::String(s) => s.to_string(), Value::Object(obj) => obj .get("text") .and_then(Value::as_str) .unwrap_or("") - .trim() .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) { - let trimmed = t.trim(); - if !trimmed.is_empty() { - parts.push(trimmed.to_string()); - } + if let Some(t) = block.get("text").and_then(Value::as_str) + && !t.is_empty() + { + parts.push(t.to_string()); } } - parts.join("\n") + parts.join("") } _ => String::new(), } @@ -82,9 +82,20 @@ fn tool_from_call(update: &Value) -> Option { .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: false, + is_error, file, command, }) From b516df59b64ffb15e72d4ff6f76a2760355936a6 Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sat, 25 Jul 2026 17:53:49 +0800 Subject: [PATCH 8/8] ci: re-trigger checks after StopHookJson protocol align Empty commit so PR #81 CI re-runs on the current delivery protocol tip.