From 332e66545ab0ba371acf7a4e09378edb61b6547f Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 12 Jul 2026 02:18:39 +0800 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] ci: re-trigger checks after StopHookJson protocol align Empty commit so PR #81 CI re-runs on the current delivery protocol tip. From f7d41d99b763f741c0cb1ac72a77f02ed9406306 Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 16 Aug 2026 20:14:10 +0800 Subject: [PATCH 09/12] fix(grok): deliver bootstrap via --rules and actually test Stop B1: launched grok never saw hcom bootstrap because SessionStart stdout is discarded. Inject get_bootstrap() through grok --rules at launch, mark name_announced, and describe the real sentinel (`hcom: wake`) instead of Cursor's ``. B2: skip claude-compat handle_stop_failure on grok hosts so a rate-limit StopFailure cannot mark a live grok session inactive. L3: stop_output_uses_additional_context_not_followup now calls handle_stop against a real pending message. Also give first-turn UPS ~6s of Enter-confirm budget so cold start does not double-type the sentinel. --- src/bootstrap.rs | 2 +- src/delivery.rs | 14 +++-- src/hooks/claude.rs | 6 +++ src/hooks/grok.rs | 64 ++++++++++++++++++----- src/launcher.rs | 125 ++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 185 insertions(+), 26 deletions(-) diff --git a/src/bootstrap.rs b/src/bootstrap.rs index bc760e24..7d6e16c8 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -142,7 +142,7 @@ 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 prompt that is only `hcom: wake` is a wake trigger, not a task. Do not answer it and do not run tools, discovery commands, `hcom listen`, or `hcom --help`. End your turn immediately. The queued hcom message will arrive automatically as your next prompt via Stop additionalContext. - 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. diff --git a/src/delivery.rs b/src/delivery.rs index 3daf5a78..db920001 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -1106,15 +1106,13 @@ pub(crate) fn inject_text(port: u16, text: &str) -> bool { } } -/// How long to wait after a Grok full-body inject before force-Enter. +/// After force-Enter of the short wake sentinel, wait this long for a real +/// submit signal (UPS `prompt`/`trigger`) before re-sending Enter. +/// Must not treat `commit_delivery_ack`'s ST_ACTIVE as success. /// -/// Scales with payload size: short pings stay snappy; large review dumps under -/// slow WSL/GB get up to ~4s so the composer can finish accepting paste. -/// After force-Enter, how long to wait for a *real* submit signal before -/// re-sending Enter. Must not treat `commit_delivery_ack`'s ST_ACTIVE as success -/// (that was a false positive that skipped retries while the composer still -/// held unsent text). -const GROK_SUBMIT_CONFIRM: Duration = Duration::from_millis(1200); +/// First-turn Grok hooks bind lazily (~4s). 2000ms × MAX_ENTER_ATTEMPTS (3) +/// ≈ 6s so a cold start does not expire and double-type `hcom: wake`. +const GROK_SUBMIT_CONFIRM: Duration = Duration::from_millis(2000); /// 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 diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index a884129a..82f23784 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -1679,6 +1679,12 @@ fn handle_permission_denied( } fn handle_stop_failure(db: &HcomDb, payload: &HookPayload, instance_name: &str) -> (i32, String) { + // Grok loads Claude-compat StopFailure too. After rate-limit / API errors it + // sits on a modal and stops emitting hooks, so ST_INACTIVE here is terminal + // (alive process, no snapshot, `hcom r` fails). Native grok-stop owns status. + if common::is_grok_host() { + return (0, String::new()); + } let error = payload .raw .get("error") diff --git a/src/hooks/grok.rs b/src/hooks/grok.rs index 705409de..f7ada99d 100644 --- a/src/hooks/grok.rs +++ b/src/hooks/grok.rs @@ -853,19 +853,59 @@ mod tests { } #[test] + #[serial] 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) + let (_tmp, hcom_dir, _home, _guard) = crate::hooks::test_helpers::isolated_test_env(); + let db = HcomDb::open().expect("db"); + db.conn() + .execute( + "INSERT INTO instances (name, tool, status, status_context, status_time, created_at, last_event_id) + VALUES ('nova', 'grok', 'listening', '', 0, 0, 0)", + [], + ) + .unwrap(); + db.set_process_binding("proc-nova", "sess-1", "nova") + .unwrap(); + let data = json!({ + "from": "luna", + "text": "hello from bus", + "scope": "broadcast", + }) + .to_string(); + db.conn() + .execute( + "INSERT INTO events (type, timestamp, instance, data) VALUES ('message', '2026-01-01T00:00:01Z', 'luna', ?1)", + rusqlite::params![data], + ) + .unwrap(); + + let mut env: std::collections::HashMap = std::env::vars().collect(); + env.insert("HCOM_PROCESS_ID".into(), "proc-nova".into()); + env.insert("HCOM_LAUNCHED".into(), "1".into()); + env.insert("HCOM_DIR".into(), hcom_dir.to_string_lossy().into_owned()); + let ctx = crate::shared::context::HcomContext::from_env( + &env, + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + ); + let payload = HookPayload::from_grok("grok-stop", json!({ "reason": "end_turn" })); + let (out, ack) = handle_stop(&db, &ctx, &payload); + + assert!( + out.get("followup_message").is_none(), + "Stop must not emit followup_message: {out}" + ); + let body = out["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap_or(""); + assert!( + body.contains("hello from bus"), + "expected additionalContext to carry the bus body, got {out}" + ); + assert!( + out["hookSpecificOutput"]["additional_context"] + .as_str() + .is_some_and(|s| s.contains("hello from bus")) ); + assert!(ack.is_some()); } } diff --git a/src/launcher.rs b/src/launcher.rs index df087228..48f6bf92 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -335,6 +335,60 @@ fn apply_inherited_notes(instance_env: &mut HashMap, inherited: } } +fn build_grok_bootstrap( + db: &HcomDb, + hcom_dir: &Path, + instance_name: &str, + background: bool, + instance_env: &HashMap, + tag: &str, + relay_enabled: bool, +) -> String { + let notes = instance_env + .get("HCOM_NOTES") + .map(String::as_str) + .unwrap_or(""); + crate::bootstrap::get_bootstrap( + db, + hcom_dir, + instance_name, + "grok", + background, + true, + notes, + tag, + relay_enabled, + None, + ) +} + +/// Append hcom bootstrap onto grok `--rules` (launch-time system-prompt channel). +/// Grok observe-hooks discard stdout, so this is the only bootstrap path. +fn inject_grok_rules(args: &mut Vec, extra: &str) { + if extra.is_empty() { + return; + } + let mut i = 0; + while i < args.len() { + let token = &args[i]; + if token == "--rules" { + if i + 1 < args.len() && !args[i + 1].starts_with('-') { + args[i + 1] = format!("{}\n\n{}", extra, args[i + 1]); + } else { + args.insert(i + 1, extra.to_string()); + } + return; + } + if let Some(rest) = token.strip_prefix("--rules=") { + args[i] = format!("--rules={}\n\n{}", extra, rest); + return; + } + i += 1; + } + args.insert(0, "--rules".to_string()); + args.insert(1, extra.to_string()); +} + fn build_codex_bootstrap( db: &HcomDb, hcom_dir: &Path, @@ -2382,13 +2436,30 @@ pub fn launch(db: &HcomDb, mut params: LaunchParams) -> Result { ) } LaunchTool::Grok => { + // Observe-only SessionStart cannot deliver bootstrap. Inject + // via grok `--rules` (appended to the system prompt) and mark + // announced so hooks do not try a discarded-stdout fallback. + let bootstrap = build_grok_bootstrap( + db, + &paths::hcom_dir(), + &instance_name, + params.background, + &instance_env, + &effective_tag, + hcom_config.relay_enabled, + ); + let mut grok_args = params.args.clone(); + inject_grok_rules(&mut grok_args, &bootstrap); + if let Some(ref sp) = params.system_prompt { + inject_grok_rules(&mut grok_args, sp); + } instances::update_instance_position( db, &instance_name, - &serde_json::Map::from_iter([( - "launch_args".to_string(), - json!(&stored_launch_args), - )]), + &serde_json::Map::from_iter([ + ("launch_args".to_string(), json!(&stored_launch_args)), + ("name_announced".to_string(), json!(true)), + ]), ); launch_pty_or_background( &mut BackgroundLaunchCtx { @@ -2403,7 +2474,7 @@ pub fn launch(db: &HcomDb, mut params: LaunchParams) -> Result { handles: &mut handles, }, &mut instance_env, - ¶ms.args, + &grok_args, ¶ms, inside_ai_tool, ) @@ -3172,6 +3243,50 @@ mod tests { unsafe { std::env::remove_var("HCOM_TAG") } } + #[test] + fn test_inject_grok_rules_prepends_when_absent() { + let mut args = vec!["--always-approve".to_string()]; + inject_grok_rules(&mut args, "BOOT"); + assert_eq!( + args, + vec![ + "--rules".to_string(), + "BOOT".to_string(), + "--always-approve".to_string() + ] + ); + } + + #[test] + fn test_inject_grok_rules_prefixes_existing_value() { + let mut args = vec!["--rules".to_string(), "user-rules".to_string()]; + inject_grok_rules(&mut args, "BOOT"); + assert_eq!(args[0], "--rules"); + assert!(args[1].starts_with("BOOT")); + assert!(args[1].contains("user-rules")); + } + + #[test] + fn test_grok_bootstrap_describes_hcom_wake_sentinel() { + let db = launcher_test_db(); + let hcom_dir = tempfile::tempdir().unwrap(); + let bootstrap = build_grok_bootstrap( + &db, + hcom_dir.path(), + "kumo", + true, + &HashMap::new(), + "reviewfix", + false, + ); + assert!(bootstrap.contains("HCOM SESSION") || bootstrap.contains("[HCOM SESSION]")); + assert!(bootstrap.contains("hcom: wake")); + assert!( + !bootstrap.contains("only `` is a wake trigger"), + "GROK_DELIVERY must not describe the Cursor/Copilot sentinel: {bootstrap}" + ); + } + #[test] fn test_codex_bootstrap_includes_notes_from_effective_instance_env() { let db = launcher_test_db(); From 60fe7c2036f220a4246da8309aa090772a72ad66 Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 16 Aug 2026 20:25:09 +0800 Subject: [PATCH 10/12] fix(grok): do not re-wake the same pending batch at Stop After the PTY sentinel is submitted, Stop still has to ack the unread row. That notify + listening flip used to look like a new idle delivery and typed a second `hcom: wake` (empty extra turn on every message). Remember the cursor we already woke for and skip re-inject until Stop advances it. Live: 3/3 rounds on stoppath-zule were 1 inject + grok.stop.additional_context + PONG, no extra transcript turn. --- src/delivery.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/delivery.rs b/src/delivery.rs index db920001..93e9d9ab 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -1117,6 +1117,16 @@ const GROK_SUBMIT_CONFIRM: Duration = Duration::from_millis(2000); /// 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. +/// True when we already submitted a wake for this unread batch and must wait +/// for grok-stop to ack instead of typing another `hcom: wake`. +fn grok_should_skip_rewake( + awaiting_at: Option, + current_cursor: i64, + has_pending: bool, +) -> bool { + has_pending && awaiting_at == Some(current_cursor) +} + 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. @@ -1461,6 +1471,11 @@ pub fn run_delivery_loop( let mut injected_text = String::new(); let mut phase_started_at = Instant::now(); let mut cursor_before: i64 = 0; + // After a Grok wake sentinel is submitted, Stop owns the pending batch. + // Remember the delivery cursor so Idle/notify does not re-type `hcom: wake` + // when Stop flips listening (same batch still unread until additionalContext + // is flushed). Cleared when pending drains or the cursor advances. + let mut grok_awaiting_stop_at: Option = None; // Gate block tracking for TUI status updates let mut block_since: Option = None; let mut last_block_context: String = String::new(); @@ -1551,7 +1566,26 @@ pub fn run_delivery_loop( // Check for pending messages let has_pending = db.has_pending(¤t_name); - if has_pending { + if !has_pending { + grok_awaiting_stop_at = None; + } + if has_pending + && config.tool == "grok" + && grok_should_skip_rewake( + grok_awaiting_stop_at, + db.get_cursor(¤t_name), + has_pending, + ) + { + log_info( + "native", + "delivery.grok_await_stop", + &format!( + "Already woke this pending batch (cursor={}); waiting for Stop additionalContext", + db.get_cursor(¤t_name) + ), + ); + } else if has_pending { log_info( "native", "delivery.wake", @@ -1582,6 +1616,7 @@ pub fn run_delivery_loop( "delivery.no_pending", &format!("No pending messages for {}", current_name), ); + grok_awaiting_stop_at = None; delivery_state = State::Idle; attempt = 0; continue; @@ -1840,8 +1875,14 @@ pub fn run_delivery_loop( ); inject_attempt = 0; attempt = 0; - // If still pending, Stop will deliver body. Stay idle - // until notify after Stop; then Pending wakes again. + // If still pending, Stop will deliver the body. Do not + // re-inject when Stop notifies + sets listening — that + // notify is for the same unread batch. + if still_pending { + grok_awaiting_stop_at = Some(db.get_cursor(¤t_name)); + } else { + grok_awaiting_stop_at = None; + } delivery_state = State::Idle; phase_started_at = Instant::now(); continue; @@ -2635,6 +2676,14 @@ mod tests { #[test] fn grok_wake_trigger_has_no_angle_brackets() { assert!(!GROK_WAKE_TRIGGER.contains('<')); + } + + #[test] + fn grok_skips_rewake_until_stop_advances_cursor() { + assert!(grok_should_skip_rewake(Some(42), 42, true)); + assert!(!grok_should_skip_rewake(Some(42), 99, true)); + assert!(!grok_should_skip_rewake(Some(42), 42, false)); + assert!(!grok_should_skip_rewake(None, 42, true)); assert!(!GROK_WAKE_TRIGGER.contains('>')); assert_eq!(GROK_WAKE_TRIGGER, "hcom: wake"); } From 10374ff4a25f829afbde56d9ada24d4054996627 Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 16 Aug 2026 21:02:51 +0800 Subject: [PATCH 11/12] fix(grok): close remaining hook, delivery, and resume gaps Claude-compat hooks under Grok are a total no-op. is_grok_host() only keys on GROK_HOOK_EVENT/NAME so a Claude child that inherited GROK_SESSION_ID still delivers. Native SubagentStart/Stop are registered; Stop classifiers match the end_turn contract Grok actually sends; hook install matches any hcom prefix. PTY: one Enter, wait for UPS, at most one retry; await-stop times out at 15s; block_on_approval is false because the composer is unscrapeable. Resume drops worktree flags; launch rejects -p/--single/--prompt-file/ --prompt-json. Transcript search uses grok_config_dir() ($GROK_HOME). Dead full-body inject helpers and discarded SessionStart env JSON are gone. cross-tool.md documents the Grok path. --- .../references/cross-tool.md | 12 +- src/commands/resume.rs | 48 +++++-- src/delivery.rs | 50 ++++--- src/hooks/claude.rs | 48 +++++++ src/hooks/common.rs | 72 +++------- src/hooks/grok.rs | 135 ++++++------------ src/integration_spec.rs | 10 +- src/launcher.rs | 19 ++- src/tools/launch_arg_validation.rs | 23 +++ src/transcript/mod.rs | 2 +- 10 files changed, 230 insertions(+), 189 deletions(-) diff --git a/skills/hcom-agent-messaging/references/cross-tool.md b/skills/hcom-agent-messaging/references/cross-tool.md index 07c44065..6a24b778 100644 --- a/skills/hcom-agent-messaging/references/cross-tool.md +++ b/skills/hcom-agent-messaging/references/cross-tool.md @@ -1,4 +1,4 @@ -# Cross-Tool Patterns: Claude + Codex + Gemini + OpenCode + Kilo Code + Pi + OMP + Antigravity + Cursor + Kimi + Copilot +# Cross-Tool Patterns: Claude + Codex + Gemini + OpenCode + Kilo Code + Pi + OMP + Antigravity + Cursor + Kimi + Copilot + Grok Verified behavior when mixing different AI coding tools via hcom. @@ -66,6 +66,16 @@ Verified behavior when mixing different AI coding tools via hcom. - **Fork**: not supported (cursor-agent has no native branch primitive — only `--resume`/`--continue`); resume preserved. - **Transcript**: cursor-agent writes JSONL under `~/.cursor/projects//agent-transcripts//.jsonl`. Parser support is limited: no timestamps, `cwd`, or tool-result blocks; user prompts require wrapper removal. +### Grok Build +- **Hooks**: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop, SessionEnd — native `~/.grok/hooks/hcom.json` (`$GROK_HOME`). +- **Payload**: JSON via stdin. Observe events discard stdout; only Stop / SubagentStop parse `hookSpecificOutput.additionalContext`. +- **Bootstrap**: launch `--rules` (system prompt append). SessionStart does not inject bootstrap. +- **Message delivery**: idle PTY types `hcom: wake` only; the bus body arrives on the next Stop. Do not `hcom listen` for inbound hcom mail. +- **Composer scrape**: none. Approval UI is not gated (`block_on_approval: false`). +- **One-shot**: `hcom grok -p` / `--single` is rejected — process exits and cannot stay on the bus. +- **Resume**: `--resume` / `--fork-session`. Worktree flags from the original launch are not replayed. +- **Transcript**: `$GROK_HOME/sessions/**/updates.jsonl` (default `~/.grok/sessions`). + ## Working Patterns See `scripts/cross-tool-duo.sh` for Claude architect + Codex engineer, and `scripts/codex-worker.sh` for Codex coder + Claude reviewer. See `patterns.md` for all 6 tested patterns including Claude + Gemini mixed perspectives. diff --git a/src/commands/resume.rs b/src/commands/resume.rs index 05a30faa..28fa113d 100644 --- a/src/commands/resume.rs +++ b/src/commands/resume.rs @@ -1050,9 +1050,8 @@ 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. +/// Drop session selectors, one-shot flags, and worktree flags (the session +/// already lives in that tree). `-w`/`--worktree` take an optional value. fn merge_grok_args(original: &[String], resume: &[String]) -> Vec { const VALUE_FLAGS: &[&str] = &[ "--model", @@ -1060,24 +1059,16 @@ fn merge_grok_args(original: &[String], resume: &[String]) -> Vec { "--cwd", "--rules", "--agent", - "--session-id", - "-s", "--permission-mode", "--reasoning-effort", "--effort", "--max-turns", "--output-format", - "--prompt-file", - "--prompt-json", "--disallowed-tools", "--tools", "--allow", "--deny", "--sandbox", - // Preserve worktree / debug / system-prompt values (owner review). - "--worktree", - "--worktree-ref", - "--ref", "--leader-socket", "--debug-file", "--system-prompt-override", @@ -1091,8 +1082,11 @@ fn merge_grok_args(original: &[String], resume: &[String]) -> Vec { "-p", "--prompt-file", "--prompt-json", + "--worktree", + "-w", + "--worktree-ref", + "--ref", ]; - // 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('-'); @@ -3682,6 +3676,36 @@ mod tests { assert_eq!(args, s(&["--resume", "sess-abc"])); } + #[test] + fn test_merge_grok_args_drops_worktree_and_keeps_rules() { + let original = s(&["--worktree", "feat", "--rules", "BOOT", "--always-approve"]); + let resume = s(&["--resume", "sess-1"]); + let merged = merge_resume_args("grok", &original, &resume); + assert!(!merged.iter().any(|t| t == "--worktree" || t == "feat")); + assert!(merged.contains(&"--rules".to_string())); + assert!(merged.contains(&"BOOT".to_string())); + assert!(merged.contains(&"--always-approve".to_string())); + } + + #[test] + fn test_merge_grok_args_bare_worktree_does_not_eat_model() { + let original = s(&["--worktree", "--model", "grok-build"]); + let resume = s(&["--resume", "sess-1"]); + let merged = merge_resume_args("grok", &original, &resume); + assert!(!merged.contains(&"--worktree".to_string())); + assert!(merged.contains(&"--model".to_string())); + assert!(merged.contains(&"grok-build".to_string())); + } + + #[test] + fn test_merge_grok_args_short_worktree_drops_name() { + let original = s(&["-w", "mytree", "--always-approve"]); + let resume = s(&["--resume", "sess-1"]); + let merged = merge_resume_args("grok", &original, &resume); + assert!(!merged.iter().any(|t| t == "-w" || t == "mytree")); + assert!(merged.contains(&"--always-approve".to_string())); + } + #[test] fn test_merge_copilot_args_preserves_model_drops_prompt() { let original = s(&["--model", "claude-haiku-4.5", "-i", "do a task"]); diff --git a/src/delivery.rs b/src/delivery.rs index 93e9d9ab..71a2b243 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -1106,17 +1106,13 @@ pub(crate) fn inject_text(port: u16, text: &str) -> bool { } } -/// After force-Enter of the short wake sentinel, wait this long for a real -/// submit signal (UPS `prompt`/`trigger`) before re-sending Enter. -/// Must not treat `commit_delivery_ack`'s ST_ACTIVE as success. -/// -/// First-turn Grok hooks bind lazily (~4s). 2000ms × MAX_ENTER_ATTEMPTS (3) -/// ≈ 6s so a cold start does not expire and double-type `hcom: wake`. -const GROK_SUBMIT_CONFIRM: Duration = Duration::from_millis(2000); +/// Wait this long after the first Enter for UPS (`prompt`/`trigger`) before +/// a single retry Enter. First-turn hooks bind lazily (~4s). +const GROK_UPS_WAIT: Duration = Duration::from_secs(4); +/// If Stop never acks this pending batch, allow one more PTY wake. +const GROK_AWAIT_STOP: Duration = Duration::from_secs(15); +const GROK_MAX_ENTER_ATTEMPTS: u32 = 2; -/// 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. /// True when we already submitted a wake for this unread batch and must wait /// for grok-stop to ack instead of typing another `hcom: wake`. fn grok_should_skip_rewake( @@ -1476,6 +1472,7 @@ pub fn run_delivery_loop( // when Stop flips listening (same batch still unread until additionalContext // is flushed). Cleared when pending drains or the cursor advances. let mut grok_awaiting_stop_at: Option = None; + let mut grok_awaiting_stop_since: Option = None; // Gate block tracking for TUI status updates let mut block_since: Option = None; let mut last_block_context: String = String::new(); @@ -1568,15 +1565,31 @@ pub fn run_delivery_loop( let has_pending = db.has_pending(¤t_name); if !has_pending { grok_awaiting_stop_at = None; + grok_awaiting_stop_since = None; } - if has_pending + let skip_rewake = has_pending && config.tool == "grok" && grok_should_skip_rewake( grok_awaiting_stop_at, db.get_cursor(¤t_name), has_pending, - ) - { + ); + let skip_expired = skip_rewake + && grok_awaiting_stop_since + .is_some_and(|since| since.elapsed() >= GROK_AWAIT_STOP); + if skip_expired { + log_info( + "native", + "delivery.grok_await_stop_timeout", + &format!( + "Stop did not ack pending batch after {:?}; allowing another wake", + GROK_AWAIT_STOP + ), + ); + grok_awaiting_stop_at = None; + grok_awaiting_stop_since = None; + } + if skip_rewake && !skip_expired { log_info( "native", "delivery.grok_await_stop", @@ -1617,6 +1630,7 @@ pub fn run_delivery_loop( &format!("No pending messages for {}", current_name), ); grok_awaiting_stop_at = None; + grok_awaiting_stop_since = None; delivery_state = State::Idle; attempt = 0; continue; @@ -1880,20 +1894,22 @@ pub fn run_delivery_loop( // notify is for the same unread batch. if still_pending { grok_awaiting_stop_at = Some(db.get_cursor(¤t_name)); + grok_awaiting_stop_since = Some(Instant::now()); } else { grok_awaiting_stop_at = None; + grok_awaiting_stop_since = None; } delivery_state = State::Idle; phase_started_at = Instant::now(); continue; } - if elapsed < GROK_SUBMIT_CONFIRM { + if elapsed < GROK_UPS_WAIT { std::thread::sleep(Duration::from_millis(50)); continue; } - if enter_attempt < MAX_ENTER_ATTEMPTS { + if enter_attempt < GROK_MAX_ENTER_ATTEMPTS { let user_active = state.is_user_active(); let approval = state.screen.read().map(|s| s.approval).unwrap_or(false); @@ -1921,7 +1937,7 @@ pub fn run_delivery_loop( "Grok still pending after {:?}; re-Enter sentinel (attempt={}/{})", elapsed, enter_attempt + 1, - MAX_ENTER_ATTEMPTS, + GROK_MAX_ENTER_ATTEMPTS, ), ); // Single Enter only — double Enter can queue two wakes. @@ -1935,7 +1951,7 @@ pub fn run_delivery_loop( "native", "delivery.grok_wake_unconfirmed", &format!( - "Grok wake unconfirmed after {MAX_ENTER_ATTEMPTS} Enters; leaving pending (no force-ack)" + "Grok wake unconfirmed after {GROK_MAX_ENTER_ATTEMPTS} Enters; leaving pending (no force-ack)" ), ); // Leave messages pending for a later idle cycle. diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index 82f23784..577e66eb 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -309,6 +309,13 @@ fn route_claude_hook( let dispatch_start = Instant::now(); let mut timing = DispatchTiming::default(); + // Grok also loads ~/.claude/settings.json. Native grok-* owns lifecycle + // and Stop additionalContext; Claude-compat must not write status or ack. + if common::is_grok_host() { + timing.result = Some("grok_compat_noop"); + return (0, String::new(), None, timing); + } + // Ensure directories and init DB if !paths::ensure_hcom_directories() { return (0, String::new(), None, timing); @@ -5088,6 +5095,47 @@ mod tests { .unwrap(); } + #[test] + #[serial] + fn grok_compat_hooks_are_total_noops() { + crate::config::Config::init(); + let (_dir, _guard, db) = make_isolated_test_db(); + db.conn() + .execute( + "INSERT INTO instances + (name, session_id, tool, status, status_context, status_time, last_seen, created_at, last_event_id) + VALUES ('nova', 'sess-1', 'grok', 'active', 'prompt', 0, 0, 0, 0)", + [], + ) + .unwrap(); + unsafe { + std::env::set_var("GROK_HOOK_EVENT", "Stop"); + std::env::remove_var("GROK_HOOK_NAME"); + } + let mut payload = HookPayload::from_claude(serde_json::json!({ + "session_id": "sess-1", + })); + let (code, stdout, ack, timing) = + route_claude_hook(&db, &make_ctx(), HOOK_POLL, &mut payload); + unsafe { + std::env::remove_var("GROK_HOOK_EVENT"); + } + assert_eq!(code, 0); + assert!(stdout.is_empty()); + assert!(ack.is_none()); + assert_eq!(timing.result, Some("grok_compat_noop")); + let (status, context): (String, String) = db + .conn() + .query_row( + "SELECT status, status_context FROM instances WHERE name = 'nova'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(status, "active"); + assert_eq!(context, "prompt"); + } + #[test] #[serial] fn test_root_bash_pretooluse_does_not_inject_actor_capability() { diff --git a/src/hooks/common.rs b/src/hooks/common.rs index 7162620a..507dafde 100644 --- a/src/hooks/common.rs +++ b/src/hooks/common.rs @@ -72,50 +72,14 @@ pub(crate) const SAFE_HCOM_COMMANDS: &[&str] = &[ "--new-terminal", ]; -/// True when this hook process was spawned by Grok Build. +/// True when this process is a Grok **hook** (Claude-compat or native). /// -/// 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`). +/// Grok injects `GROK_HOOK_EVENT` / `GROK_HOOK_NAME` on hook invocations and +/// also loads `~/.claude/settings.json`. Those hook-only vars distinguish a +/// Grok-spawned hook from a descendant tool that merely inherited +/// `GROK_SESSION_ID` (e.g. Claude started via grok `run_terminal_command`). 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("->")) + std::env::var_os("GROK_HOOK_EVENT").is_some() || std::env::var_os("GROK_HOOK_NAME").is_some() } #[cfg(test)] @@ -137,28 +101,30 @@ mod grok_host_tests { #[test] #[serial] - fn is_grok_host_true_with_session_id() { + fn is_grok_host_false_with_session_id_alone() { 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()); + 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)); + #[serial] + fn is_grok_host_true_with_hook_event() { + unsafe { + std::env::remove_var("GROK_SESSION_ID"); + std::env::set_var("GROK_HOOK_EVENT", "Stop"); + std::env::remove_var("GROK_HOOK_NAME"); + } + assert!(is_grok_host()); + unsafe { + std::env::remove_var("GROK_HOOK_EVENT"); + } } } diff --git a/src/hooks/grok.rs b/src/hooks/grok.rs index f7ada99d..86ce1e26 100644 --- a/src/hooks/grok.rs +++ b/src/hooks/grok.rs @@ -30,6 +30,8 @@ const GROK_HOOK_COMMANDS: &[(&str, &str)] = &[ ("PreToolUse", "grok-pretooluse"), ("PostToolUse", "grok-posttooluse"), ("Stop", "grok-stop"), + ("SubagentStart", "grok-subagentstart"), + ("SubagentStop", "grok-subagentstop"), ("SessionEnd", "grok-sessionend"), ]; @@ -68,7 +70,7 @@ pub enum SetupError { } /// Resolve Grok config root: `$GROK_HOME` if set, else `/.grok`. -fn grok_config_dir() -> PathBuf { +pub fn grok_config_dir() -> PathBuf { if let Ok(home) = std::env::var("GROK_HOME") { let trimmed = home.trim(); if !trimmed.is_empty() { @@ -99,10 +101,8 @@ fn build_grok_hook_command(command: &str) -> String { } fn is_hcom_grok_command(command: &str) -> bool { - let trimmed = command.trim(); - GROK_HOOK_COMMANDS - .iter() - .any(|(_, suffix)| trimmed == build_grok_hook_command(suffix)) + let last = command.split_whitespace().last().unwrap_or(""); + GROK_HOOK_COMMANDS.iter().any(|(_, suffix)| last == *suffix) } fn expected_command_hook(command: &str) -> Value { @@ -369,30 +369,6 @@ fn update_position(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload, instan 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); @@ -401,7 +377,7 @@ fn resolved_instance(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> O 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) }); + return json!({}); }; let instance_name = ctx .process_id @@ -409,7 +385,7 @@ fn handle_sessionstart(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> .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) }); + return json!({}); }; let _ = db.rebind_instance_session(&instance_name, &session_id); instance_binding::capture_and_store_launch_context(db, &instance_name); @@ -424,9 +400,22 @@ 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); - // 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) }) + // SessionStart is observe-only: stdout discarded. Bootstrap is --rules. + json!({}) +} + +fn handle_subagentstart(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> Value { + if let Some(instance) = resolved_instance(db, ctx, payload) { + lifecycle::set_status( + db, + &instance.name, + ST_LISTENING, + "subagent-start", + Default::default(), + ); + common::notify_hook_instance_with_db(db, &instance.name); + } + json!({}) } fn handle_userpromptsubmit( @@ -485,47 +474,9 @@ fn handle_posttooluse( (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. +/// Grok Stop always sends `reason: "end_turn"`. Empty / completed are accepted +/// as the same gate. Session teardown is `SessionEnd`, not a Stop reason. 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") @@ -534,8 +485,6 @@ fn is_genuine_end_turn_stop(payload: &HookPayload) -> bool { .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" @@ -554,20 +503,6 @@ fn handle_stop( lifecycle::set_status(db, &instance.name, ST_LISTENING, "", Default::default()); common::notify_hook_instance_with_db(db, &instance.name); - if is_session_end_stop(payload) { - log::log_info( - "hooks", - "grok.stop.session_end_skip", - &format!( - "instance={} — no deliver/ack on session-end Stop", - instance.name - ), - ); - return (json!({}), None); - } - if is_cancellable_stop_status(payload) { - return (json!({}), None); - } if !is_genuine_end_turn_stop(payload) { return (json!({}), None); } @@ -651,6 +586,8 @@ pub fn dispatch_grok_hook(hook_name: &str) -> i32 { "grok-pretooluse" => (handle_pretooluse(&db, &ctx, &payload), None), "grok-posttooluse" => handle_posttooluse(&db, &ctx, &payload), "grok-stop" => handle_stop(&db, &ctx, &payload), + "grok-subagentstart" => (handle_subagentstart(&db, &ctx, &payload), None), + "grok-subagentstop" => handle_stop(&db, &ctx, &payload), "grok-sessionend" => (handle_sessionend(&db, &ctx, &payload), None), _ => (json!({}), None), }, @@ -771,6 +708,7 @@ mod tests { .count(), 1 ); + assert!(!all_commands.contains(&"uvx hcom grok-stop")); assert!(all_commands.contains(&"./custom-stop.sh")); } @@ -844,12 +782,21 @@ mod tests { } #[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)); + fn end_turn_is_the_only_stop_delivery_reason() { let payload = HookPayload::from_grok("grok-stop", json!({ "reason": "end_turn" })); - assert!(!is_session_end_stop(&payload)); assert!(is_genuine_end_turn_stop(&payload)); + let payload = HookPayload::from_grok("grok-stop", json!({})); + assert!(is_genuine_end_turn_stop(&payload)); + let payload = HookPayload::from_grok("grok-stop", json!({ "reason": "channel_closed" })); + assert!(!is_genuine_end_turn_stop(&payload)); + } + + #[test] + fn is_hcom_grok_command_matches_prefix_variants() { + assert!(is_hcom_grok_command("hcom grok-stop")); + assert!(is_hcom_grok_command("uvx hcom grok-stop")); + assert!(is_hcom_grok_command("hcom grok-subagentstop")); + assert!(!is_hcom_grok_command("./custom-stop.sh")); } #[test] diff --git a/src/integration_spec.rs b/src/integration_spec.rs index e6c280af..bd5b7798 100644 --- a/src/integration_spec.rs +++ b/src/integration_spec.rs @@ -320,6 +320,8 @@ const GROK_HOOKS: &[&str] = &[ "grok-pretooluse", "grok-posttooluse", "grok-stop", + "grok-subagentstart", + "grok-subagentstop", "grok-sessionend", ]; @@ -406,11 +408,6 @@ 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"), - // -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 +1105,8 @@ pub static GROK: IntegrationSpec = IntegrationSpec { // permanently report prompt_has_text and block PTY inject forever. require_prompt_empty: false, block_on_user_activity: true, - block_on_approval: true, + // Composer scrape is None; do not pretend we can see Grok approval UI. + block_on_approval: false, // Launch readiness falls back to settle-timeout without a ready pattern. launch_requires_ready: false, launch_ready_on_plugin_bind: false, diff --git a/src/launcher.rs b/src/launcher.rs index 48f6bf92..44634dd0 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -23,8 +23,9 @@ use crate::shared::constants::HCOM_IDENTITY_VARS; use crate::shared::tool_detection::tool_marker_vars; use crate::terminal; use crate::tools::launch_arg_validation::{ - ANTIGRAVITY_REJECTED_ARGS, GEMINI_REJECTED_ARGS, KILO_REJECTED_ARGS, KIMI_REJECTED_ARGS, - OMP_REJECTED_ARGS, OPENCODE_REJECTED_ARGS, PI_REJECTED_ARGS, validate_rejected_args, + ANTIGRAVITY_REJECTED_ARGS, GEMINI_REJECTED_ARGS, GROK_REJECTED_ARGS, KILO_REJECTED_ARGS, + KIMI_REJECTED_ARGS, OMP_REJECTED_ARGS, OPENCODE_REJECTED_ARGS, PI_REJECTED_ARGS, + validate_rejected_args, }; use crate::tools::{ codex_preprocessing, copilot_preprocessing, cursor_preprocessing, opencode_preprocessing, @@ -2577,9 +2578,7 @@ 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(), + LaunchTool::Grok => validate_rejected_args("Grok", "hcom grok", args, GROK_REJECTED_ARGS), } } @@ -2807,6 +2806,16 @@ mod tests { assert_eq!(args.last().map(String::as_str), Some("hcom")); } + #[test] + fn validate_grok_rejects_one_shot() { + let errors = validate_tool_args(&LaunchTool::Grok, &["-p".to_string(), "task".to_string()]); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("-p") || errors[0].contains("single")); + assert!( + validate_tool_args(&LaunchTool::Grok, &["--always-approve".to_string()]).is_empty() + ); + } + #[test] fn validate_cursor_print_mode_fails_fast() { let errors = validate_tool_args(&LaunchTool::Cursor, &["--print".to_string()]); diff --git a/src/tools/launch_arg_validation.rs b/src/tools/launch_arg_validation.rs index 141b809e..f0d17e1d 100644 --- a/src/tools/launch_arg_validation.rs +++ b/src/tools/launch_arg_validation.rs @@ -13,6 +13,29 @@ pub(crate) enum RejectedArgKind { RootSubcommand, } +pub(crate) const GROK_REJECTED_ARGS: &[RejectedArg] = &[ + RejectedArg { + token: "-p", + reason: "exits after one prompt; not a persistent hcom agent", + kind: RejectedArgKind::Flag, + }, + RejectedArg { + token: "--single", + reason: "exits after one prompt; not a persistent hcom agent", + kind: RejectedArgKind::Flag, + }, + RejectedArg { + token: "--prompt-file", + reason: "one-shot prompt from a file; not a persistent hcom agent", + kind: RejectedArgKind::Flag, + }, + RejectedArg { + token: "--prompt-json", + reason: "one-shot JSON prompt; not a persistent hcom agent", + kind: RejectedArgKind::Flag, + }, +]; + pub(crate) const KIMI_REJECTED_ARGS: &[RejectedArg] = &[ RejectedArg { token: "-p", diff --git a/src/transcript/mod.rs b/src/transcript/mod.rs index dcfb656a..3f473efe 100644 --- a/src/transcript/mod.rs +++ b/src/transcript/mod.rs @@ -551,7 +551,7 @@ 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")] + vec![crate::hooks::grok::grok_config_dir().join("sessions")] } TranscriptDiscovery::OpenCodeDatabase | TranscriptDiscovery::KiloDatabase => Vec::new(), } From dead09b6ccedfa81389c41bc977bdf98b299b554 Mon Sep 17 00:00:00 2001 From: KIALA9 Date: Sun, 13 Sep 2026 11:27:28 +0800 Subject: [PATCH 12/12] fix(grok): deliver messages through native ACP without disturbing drafts Keep the existing hcom mailbox authoritative and acknowledge only matching native end_turn completion. Preserve unread messages after ambiguous delivery without replay or composer injection. Verify stock Grok 1.0.13 busy roundtrip, one-time permissions, headless wake, and handled disconnect. Reject unsupported optional policy flags before agent creation; ordinary launches do not require a custom Grok build. --- .../references/cross-tool.md | 7 +- src/bootstrap.rs | 4 +- src/delivery.rs | 193 +++- src/delivery/grok.rs | 856 ++++++++++++++++++ src/hooks/grok.rs | 75 +- src/integration_spec.rs | 12 +- src/launcher.rs | 27 +- src/main.rs | 13 +- src/pty/inject.rs | 6 +- src/pty/mod.rs | 25 + src/pty/shared.rs | 164 ++++ src/pty/win.rs | 66 +- 12 files changed, 1401 insertions(+), 47 deletions(-) create mode 100644 src/delivery/grok.rs diff --git a/skills/hcom-agent-messaging/references/cross-tool.md b/skills/hcom-agent-messaging/references/cross-tool.md index 6a24b778..f08a32f0 100644 --- a/skills/hcom-agent-messaging/references/cross-tool.md +++ b/skills/hcom-agent-messaging/references/cross-tool.md @@ -70,8 +70,11 @@ Verified behavior when mixing different AI coding tools via hcom. - **Hooks**: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStart, SubagentStop, SessionEnd — native `~/.grok/hooks/hcom.json` (`$GROK_HOME`). - **Payload**: JSON via stdin. Observe events discard stdout; only Stop / SubagentStop parse `hookSpecificOutput.additionalContext`. - **Bootstrap**: launch `--rules` (system prompt append). SessionStart does not inject bootstrap. -- **Message delivery**: idle PTY types `hcom: wake` only; the bus body arrives on the next Stop. Do not `hcom listen` for inbound hcom mail. -- **Composer scrape**: none. Approval UI is not gated (`block_on_approval: false`). +- **Message delivery**: managed `hcom grok` and `hcom grok --headless` sessions use a thin native ACP client connected to the same Grok leader/session. Message bodies enter Grok's native queue; no composer wake text or Enter is injected. Stop observes activity but does not consume the ACP-owned mailbox. Unmanaged hook-only sessions retain Stop additionalContext delivery. +- **Delivery confirmation**: only a matching successful `end_turn` advances the existing mailbox cursor. Queue notifications alone do not acknowledge delivery. Cancellation, failed/ambiguous writes, disconnect, or a session change during delivery retain unread mail and stop automatic replay (`acp_unacknowledged`); no input-box fallback occurs. Inspect the session before restarting, since an interrupted transport may already have delivered the message. +- **Composer and permissions**: the native TUI retains its draft/cursor and handles permission prompts. hcom never auto-approves or auto-rejects ACP permission requests. Ordinary managed sessions work with stock Grok; a custom build is not required. The final Windows integration was tested with official Grok 1.0.13: real hcom replies, multiline draft/mid-text cursor preservation through a permission dialog, busy queue, unattended headless wake, and handled transport interruption. +- **Startup restrictions**: hcom owns the leader endpoint. `--no-subagents` is forwarded as native `GROK_SUBAGENTS=0`. Optional `--allow/--deny` (including aliases) and `--disable-web-search` require native `agent leader --launch-policy` support; hcom checks that capability only when those flags are requested. Official Grok 1.0.13 rejects these restricted launches before an agent is created, rather than silently losing restrictions. Ordinary launches do not run this capability probe. On a capable binary, both TUI and ACP receive the same restriction arguments; no hcom policy store is added. +- **Native safety gates**: allow rules do not bypass Grok's Bash/shell preflight. PowerShell call-operator syntax (`& "program"`) can still require native confirmation when the Bash parser cannot safely decompose it. hcom does not override that decision. - **One-shot**: `hcom grok -p` / `--single` is rejected — process exits and cannot stay on the bus. - **Resume**: `--resume` / `--fork-session`. Worktree flags from the original launch are not replayed. - **Transcript**: `$GROK_HOME/sessions/**/updates.jsonl` (default `~/.grok/sessions`). diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 7d6e16c8..e4b1e32c 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -141,8 +141,8 @@ 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 `hcom: wake` is a wake trigger, not a task. Do not answer it and do not run tools, discovery commands, `hcom listen`, or `hcom --help`. End your turn immediately. The queued hcom message will arrive automatically as your next prompt via Stop additionalContext. +Managed Grok TUI and headless sessions receive hcom messages through Grok's native queue, without typing into your composer. Hooks keep hcom informed of your activity: +- Legacy hook-only delivery may use a prompt that is only `hcom: wake`. It is a wake trigger, not a task. Do not answer it or run tools, discovery commands, `hcom listen`, or `hcom --help`. End your turn immediately; legacy pending mail arrives via Stop additionalContext. - 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. diff --git a/src/delivery.rs b/src/delivery.rs index 71a2b243..b47a9b87 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -2,8 +2,9 @@ #[path = "delivery/antigravity.rs"] mod antigravity; +pub(crate) mod grok; -use std::io::Write; +use std::io::{Read, Write}; use std::net::TcpStream; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -384,6 +385,9 @@ pub(crate) fn gate_block_detail(reason: &str) -> &'static str { "not_ready" => "prompt not visible", "output_unstable" => "output still streaming", "prompt_has_text" => "uncommitted text in prompt", + "prompt_unverified" => { + "Grok composer cannot be verified; idle wake requires an unattended --headless worker" + } "approval" => "waiting for user approval", "nav_overlay" => "waiting for subagent nav / session switcher to close", _ => "blocked", @@ -593,6 +597,9 @@ pub struct GateResult { /// Shared state for delivery thread pub struct DeliveryState { pub screen: Arc>, + /// Explicit headless Grok worker with no interactive terminal on stdin. + pub grok_unattended: bool, + pub grok_acp: Option, /// True while the launch outcome is still Pending. Cleared once any /// terminal outcome (ready/failed/blocked) fires, so the PTY proxy can /// stop computing launch-only signals (e.g. `visible_tail`). @@ -667,6 +674,8 @@ pub struct ScreenState { pub input_text: Option, pub visible_tail: Option, pub last_user_input: Instant, + /// Sticky for this PTY lifetime: a cooldown cannot prove a draft was cleared. + pub user_input_seen: bool, /// Timestamp of last output (for stability-based recovery) pub last_output: Instant, /// Terminal width in columns @@ -705,6 +714,7 @@ impl Default for ScreenState { input_text: None, visible_tail: None, last_user_input: Instant::now(), + user_input_seen: false, last_output: Instant::now(), cols: 80, last_prompt_submit: None, @@ -829,6 +839,16 @@ pub(crate) fn evaluate_gate( reason: "not_ready", }; } + if config.tool == "grok" && !grok_prompt_owned(&screen, state.grok_unattended, "") { + return GateResult { + safe: false, + reason: if screen.input_text.is_some() { + "prompt_has_text" + } else { + "prompt_unverified" + }, + }; + } if config.require_prompt_empty && !screen.prompt_empty { return GateResult { safe: false, @@ -1134,6 +1154,28 @@ fn grok_turn_started(status: &str, context: &str) -> bool { || context.starts_with("approved:") } +/// Acknowledged internal wake commands let the PTY distinguish automation from +/// operator input and re-check ownership at the actual write boundary. +pub(crate) fn inject_grok_command(port: u16, enter: bool) -> bool { + let Ok(mut stream) = TcpStream::connect(format!("127.0.0.1:{port}")) else { + return false; + }; + let timeout = Some(Duration::from_secs(2)); + if stream.set_read_timeout(timeout).is_err() || stream.set_write_timeout(timeout).is_err() { + return false; + } + let command: &[u8] = if enter { + b"\0GROK_ENTER" + } else { + b"\0GROK_WAKE" + }; + if stream.write_all(command).is_err() || stream.shutdown(std::net::Shutdown::Write).is_err() { + return false; + } + let mut response = String::new(); + stream.take(64).read_to_string(&mut response).is_ok() && response == "ok\n" +} + /// Inject Enter key to PTY via TCP pub(crate) fn inject_enter(port: u16) -> bool { match TcpStream::connect(format!("127.0.0.1:{}", port)) { @@ -1174,6 +1216,23 @@ fn prompt_ownership(input_text: Option<&str>, injected_text: &str) -> PromptOwne } } +pub(crate) fn grok_prompt_owned(screen: &ScreenState, unattended: bool, expected: &str) -> bool { + match screen.input_text.as_deref() { + Some(input) => input == expected, + None => unattended && !screen.user_input_seen, + } +} + +impl DeliveryState { + /// Both the first Enter and every retry must retain submit authority. + fn grok_can_submit(&self, injected_text: &str) -> bool { + let screen = self.screen.read().unwrap(); + !self.is_user_active_with_guard(&screen) + && !screen.approval + && grok_prompt_owned(&screen, self.grok_unattended, injected_text) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Phase1Decision { Rendered, @@ -1361,7 +1420,23 @@ pub fn run_delivery_loop( // After that, the plugin takes over (messages.transform for active, promptAsync for idle). use crate::tool::Tool; use std::str::FromStr; - if matches!( + if let Some(launch) = state.grok_acp.as_ref().filter(|_| config.tool == "grok") { + grok::run( + launch, + &running, + db, + notify, + state, + &process_id, + &mut current_name, + config, + &shared_name, + &shared_status, + &title_wake, + &mut host_label, + &mut launch_outcome, + ); + } else if matches!( Tool::from_str(&config.tool), Ok(Tool::OpenCode | Tool::Kilo | Tool::Pi | Tool::Omp) ) { @@ -1683,7 +1758,11 @@ pub fn run_delivery_loop( // 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); + let inject_ok = if config.tool == "grok" { + inject_grok_command(state.inject_port, false) + } else { + inject_text(state.inject_port, &text) + }; if inject_ok { log_info( @@ -1866,7 +1945,8 @@ pub fn run_delivery_loop( State::WaitTextRender => { let elapsed = phase_started_at.elapsed(); - // Grok: unscrapeable composer → cannot prove ownership. + // Grok: an unobserved composer is only owned by a headless + // worker that has never received human input. // Protocol (owner review): PTY only submits a short wake // sentinel; real body is delivered from grok-stop via // hookSpecificOutput.additionalContext. Never force-ack. @@ -1913,13 +1993,13 @@ pub fn run_delivery_loop( let user_active = state.is_user_active(); let approval = state.screen.read().map(|s| s.approval).unwrap_or(false); - if user_active || approval { + if !state.grok_can_submit(&injected_text) { if elapsed > PHASE1_TIMEOUT { log_warn( "native", "delivery.grok_enter_retry_blocked", &format!( - "Grok Enter retry blocked (user_active={user_active}, approval={approval}); pending kept" + "Grok Enter retry lacks submit authority (user_active={user_active}, approval={approval}); pending kept" ), ); delivery_state = State::Pending; @@ -1941,7 +2021,7 @@ pub fn run_delivery_loop( ), ); // Single Enter only — double Enter can queue two wakes. - inject_enter(state.inject_port); + inject_grok_command(state.inject_port, true); enter_attempt += 1; phase_started_at = Instant::now(); continue; @@ -1970,13 +2050,13 @@ pub fn run_delivery_loop( } let user_active = state.is_user_active(); let approval = state.screen.read().map(|s| s.approval).unwrap_or(false); - if user_active || approval { + if !state.grok_can_submit(&injected_text) { if elapsed > PHASE1_TIMEOUT { log_warn( "native", "delivery.grok_enter_blocked", &format!( - "Grok force-Enter blocked (user_active={user_active}, approval={approval})" + "Grok Enter lacks submit authority (user_active={user_active}, approval={approval}); pending kept" ), ); delivery_state = State::Pending; @@ -1995,7 +2075,7 @@ pub fn run_delivery_loop( injected_text.len(), ), ); - inject_enter(state.inject_port); + inject_grok_command(state.inject_port, true); enter_attempt = 1; phase_started_at = Instant::now(); continue; @@ -2537,7 +2617,9 @@ mod tests { /// Helper: create DeliveryState with given screen state fn make_state(screen: ScreenState, cooldown_ms: u64) -> DeliveryState { DeliveryState { + grok_acp: None, screen: Arc::new(std::sync::RwLock::new(screen)), + grok_unattended: false, launch_phase_active: Arc::new(AtomicBool::new(true)), inject_port: 0, user_activity_cooldown_ms: cooldown_ms, @@ -2553,6 +2635,7 @@ mod tests { input_text: None, visible_tail: None, last_user_input: Instant::now() - Duration::from_secs(10), + user_input_seen: false, last_output: Instant::now() - Duration::from_secs(10), cols: 80, last_prompt_submit: None, @@ -2704,6 +2787,96 @@ mod tests { assert_eq!(GROK_WAKE_TRIGGER, "hcom: wake"); } + #[test] + fn grok_interactive_unknown_composer_blocks_wake_after_cooldown() { + let config = ToolConfig::for_tool(Tool::Grok); + let state = make_state(safe_screen(), 500); + let gate = evaluate_gate(&config, &state, true); + assert!(!gate.safe); + assert_eq!(gate.reason, "prompt_unverified"); + assert!(!state.grok_can_submit(GROK_WAKE_TRIGGER)); + } + + #[test] + fn grok_headless_unknown_composer_retains_automatic_wake() { + let config = ToolConfig::for_tool(Tool::Grok); + let mut screen = safe_screen(); + screen.prompt_empty = false; + let mut state = make_state(screen, 500); + state.grok_unattended = true; + assert!(evaluate_gate(&config, &state, true).safe); + assert!(state.grok_can_submit(GROK_WAKE_TRIGGER)); + assert!(!evaluate_gate(&config, &state, false).safe); + + state.screen.write().unwrap().approval = true; + assert!(!evaluate_gate(&config, &state, true).safe); + assert!(!state.grok_can_submit(GROK_WAKE_TRIGGER)); + } + + #[test] + fn grok_launch_readiness_does_not_require_an_idle_injection_grant() { + let dir = tempfile::tempdir().unwrap(); + let db = HcomDb::open_raw(&dir.path().join("test.db")).unwrap(); + db.init_db().unwrap(); + let config = ToolConfig::for_tool(Tool::Grok); + for unattended in [false, true] { + let mut screen = safe_screen(); + screen.prompt_empty = false; + let mut state = make_state(screen, 500); + state.grok_unattended = unattended; + assert!(launch_ready_observed(&db, "nova", &config, &state)); + assert_eq!(evaluate_gate(&config, &state, true).safe, unattended); + } + } + + #[test] + fn grok_human_input_permanently_revokes_unknown_composer_ownership() { + let config = ToolConfig::for_tool(Tool::Grok); + let mut state = make_state(safe_screen(), 500); + state.grok_unattended = true; + assert!(state.grok_can_submit(GROK_WAKE_TRIGGER)); + { + let mut screen = state.screen.write().unwrap(); + screen.user_input_seen = true; + screen.last_user_input = Instant::now() - Duration::from_secs(60); + } + assert!(!evaluate_gate(&config, &state, true).safe); + assert!(!state.grok_can_submit(GROK_WAKE_TRIGGER)); + } + + #[test] + fn grok_headless_never_overrides_an_observed_draft() { + let config = ToolConfig::for_tool(Tool::Grok); + for unattended in [false, true] { + for draft in [ + "private draft", + "private draft\nhcom: wake", + "hcom: wake private draft", + ] { + let mut screen = safe_screen(); + screen.prompt_empty = false; + screen.input_text = Some(draft.into()); + let mut state = make_state(screen, 500); + state.grok_unattended = unattended; + assert!(!evaluate_gate(&config, &state, true).safe); + assert!(!state.grok_can_submit(GROK_WAKE_TRIGGER)); + } + } + } + + #[test] + fn grok_verified_sentinel_loses_submit_authority_when_draft_changes() { + let mut screen = safe_screen(); + screen.prompt_empty = false; + screen.input_text = Some(GROK_WAKE_TRIGGER.into()); + let state = make_state(screen, 500); + assert!(state.grok_can_submit(GROK_WAKE_TRIGGER)); + state.screen.write().unwrap().input_text = Some("hcom: wake edited by user".into()); + assert!(!state.grok_can_submit(GROK_WAKE_TRIGGER)); + state.screen.write().unwrap().input_text = None; + assert!(!state.grok_can_submit(GROK_WAKE_TRIGGER)); + } + #[test] fn phase1_timeout_is_ten_seconds() { assert_eq!(PHASE1_TIMEOUT, Duration::from_secs(10)); diff --git a/src/delivery/grok.rs b/src/delivery/grok.rs new file mode 100644 index 00000000..a38fa2e8 --- /dev/null +++ b/src/delivery/grok.rs @@ -0,0 +1,856 @@ +//! Thin native ACP transport. The existing hcom mailbox remains authoritative. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock, mpsc}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use serde_json::{Value, json}; + +use crate::db::HcomDb; +use crate::hooks::{DeliveryAck, common}; +use crate::notify::NotifyServer; +use crate::shared::ST_LISTENING; + +use super::{DeliveryState, LaunchOutcome, TitleWake, ToolConfig, log_info, log_warn}; + +const POLL: Duration = Duration::from_millis(100); +const SETUP_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Clone, Debug)] +pub(crate) struct Launch { + command: String, + prefix: Vec, + socket: String, + no_subagents: bool, + policy_args: Vec, +} + +impl Launch { + pub(crate) fn validate_args(args: &[&str]) -> Result<()> { + if args.iter().take_while(|arg| **arg != "--").any(|arg| { + matches!(*arg, "--leader" | "--no-leader" | "--leader-socket") + || arg.starts_with("--leader-socket=") + }) { + bail!("hcom manages Grok's leader connection; remove custom leader flags"); + } + Self::policy_args(args)?; + Ok(()) + } + + fn policy_args(args: &[&str]) -> Result> { + let mut policy = Vec::new(); + let mut args = args.iter().take_while(|arg| **arg != "--"); + while let Some(arg) = args.next() { + let flag = arg.split('=').next().unwrap_or(arg); + if matches!( + flag, + "--allow" + | "--deny" + | "--allowedTools" + | "--disallowedTools" + | "--disable-web-search" + ) { + policy.push((*arg).to_string()); + if flag != "--disable-web-search" && !arg.contains('=') { + policy.push( + args.next() + .with_context(|| format!("{flag} requires a rule"))? + .to_string(), + ); + } + } + } + Ok(policy) + } + + pub(crate) fn check_policy_support(mut command: Command, args: &[&str]) -> Result<()> { + if Self::policy_args(args)?.is_empty() { + return Ok(()); + } + // Probe the actual inherited-policy CLI entry, not a version number. + command + .args(["agent", "leader", "--launch-policy", "{}", "--help"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let mut child = command + .spawn() + .context("check native Grok policy support")?; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(_)) => bail!( + "This Grok binary does not support leader policy inheritance; use a policy-capable Grok build for --allow/--deny or --disable-web-search" + ), + Ok(None) if Instant::now() < deadline => std::thread::sleep(POLL), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + bail!("Native Grok policy support check timed out; launch stopped"); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error).context("check native Grok policy support"); + } + } + } + } + + pub(crate) fn new(command: &str, prefix: &[String], args: &[&str]) -> Result { + Self::validate_args(args)?; + let mut probe = Command::new(command); + probe.args(prefix); + Self::check_policy_support(probe, args)?; + Ok(Self { + command: command.to_string(), + prefix: prefix.to_vec(), + socket: std::env::temp_dir() + .join(format!("hcom-grok-{}.sock", uuid::Uuid::new_v4())) + .to_string_lossy() + .into_owned(), + no_subagents: args + .iter() + .take_while(|arg| **arg != "--") + .any(|arg| *arg == "--no-subagents"), + policy_args: Self::policy_args(args)?, + }) + } + + pub(crate) fn tui_args(&self) -> Vec { + vec![ + "--leader".into(), + "--leader-socket".into(), + self.socket.clone(), + ] + } + + pub(crate) fn child_env(&self) -> Vec<(String, String)> { + let mut env = vec![("HCOM_GROK_ACP".into(), "1".into())]; + if self.no_subagents { + env.push(("GROK_SUBAGENTS".into(), "0".into())); + } + env + } +} + +enum Event { + Response(Value), + Queue(Value), + Interaction(String), + Closed(String), +} + +struct Client { + child: Child, + input: ChildStdin, + events: mpsc::Receiver, + next_id: u64, +} + +impl Client { + fn connect( + launch: &Launch, + session: &str, + cwd: &str, + running: &AtomicBool, + deadline: Instant, + ) -> Result { + if Instant::now() >= deadline { + bail!("ACP setup deadline expired"); + } + let mut command = Command::new(&launch.command); + command + .args(&launch.prefix) + .args(&launch.policy_args) + .args([ + "agent", + "--leader", + "--leader-socket", + &launch.socket, + "stdio", + ]); + command + .current_dir(cwd) + .envs(launch.child_env()) + .env("HCOM_LAUNCHED", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); // CREATE_NO_WINDOW + } + let mut child = command.spawn().context("start native Grok ACP client")?; + let input = child.stdin.take().context("ACP stdin unavailable")?; + let output = child.stdout.take().context("ACP stdout unavailable")?; + let (sender, events) = mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(output).lines() { + let event = match line { + Ok(line) => match serde_json::from_str::(&line) { + Ok(value) => classify_event(value), + Err(_) => Some(Event::Closed("invalid JSON from native ACP client".into())), + }, + Err(error) => Some(Event::Closed(format!("ACP read failed: {error}"))), + }; + if let Some(event) = event { + let closed = matches!(event, Event::Closed(_)); + if sender.send(event).is_err() || closed { + return; + } + } + } + let _ = sender.send(Event::Closed("native ACP client disconnected".into())); + }); + let mut client = Self { + child, + input, + events, + next_id: 0, + }; + client.request("initialize", json!({ + "protocolVersion": 1, + "clientCapabilities": {"fs": {"readTextFile": false, "writeTextFile": false}, "terminal": false}, + "clientInfo": {"name": "hcom", "version": env!("CARGO_PKG_VERSION")} + }), running, deadline)?; + client.request( + "authenticate", + json!({"methodId": "cached_token"}), + running, + deadline, + )?; + client.request( + "session/load", + json!({"sessionId": session, "cwd": cwd, "mcpServers": []}), + running, + deadline, + )?; + log_info( + "native", + "grok.acp.connected", + &format!("session={session}"), + ); + Ok(client) + } + + fn send(&mut self, method: &str, params: Value) -> Result { + self.next_id += 1; + let id = self.next_id; + let request = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + serde_json::to_writer(&mut self.input, &request)?; + self.input.write_all(b"\n")?; + self.input.flush()?; + Ok(id) + } + + fn request( + &mut self, + method: &str, + params: Value, + running: &AtomicBool, + deadline: Instant, + ) -> Result { + if Instant::now() >= deadline { + bail!("{method}: ACP setup deadline expired"); + } + let id = self.send(method, params)?; + while running.load(Ordering::Acquire) && Instant::now() < deadline { + match self.events.recv_timeout(POLL) { + Ok(Event::Response(value)) if value["id"].as_u64() == Some(id) => { + if let Some(error) = value.get("error") { + bail!("{method}: {error}"); + } + return Ok(value["result"].clone()); + } + Ok(Event::Closed(error)) => bail!("{error}"), + Ok(event) => observe_event(event), + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => bail!("ACP reader stopped"), + } + } + bail!("{method}: setup timed out or PTY stopped") + } +} + +impl Drop for Client { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn classify_event(value: Value) -> Option { + match value.get("method").and_then(Value::as_str) { + Some("_x.ai/queue/changed" | "x.ai/queue/changed") => { + Some(Event::Queue(value["params"].clone())) + } + Some(method) if value.get("id").is_some() => Some(Event::Interaction(method.to_string())), + Some(_) => None, + None if value.get("id").is_some() => Some(Event::Response(value)), + None => None, + } +} + +fn observe_event(event: Event) { + match event { + Event::Queue(params) => { + let ids = params["entries"] + .as_array() + .map(|entries| { + entries + .iter() + .filter_map(|entry| entry["id"].as_str()) + .collect::>() + }) + .unwrap_or_default(); + log_info( + "native", + "grok.acp.queue", + &format!( + "session={} running={} pending={ids:?}", + params["sessionId"].as_str().unwrap_or(""), + params["runningPromptId"].as_str().unwrap_or("") + ), + ); + } + // The leader broadcasts interactions to the TUI as well. Do not race + // the user's answer with a synthetic approval, cancellation or error. + Event::Interaction(method) => log_info( + "native", + "grok.acp.interaction", + &format!("TUI owns {method}"), + ), + _ => {} + } +} + +struct InFlight { + request_id: u64, + session: String, + ack: DeliveryAck, +} + +fn completed_response(value: &Value, id: u64) -> Option> { + if value["id"].as_u64() != Some(id) { + return None; + } + Some(if value.get("error").is_some() { + Err(anyhow::anyhow!("prompt failed: {}", value["error"])) + } else if value["result"]["stopReason"].as_str() == Some("end_turn") { + Ok(()) + } else { + Err(anyhow::anyhow!( + "prompt did not complete: {}", + value["result"]["stopReason"] + )) + }) +} + +fn acknowledge(db: &HcomDb, flight: &InFlight) -> Result<()> { + // Native hooks already publish current activity. Commit only the existing + // mailbox cursor, monotonically, without overwriting a newer TUI status. + let changed = db.conn().execute( + "UPDATE instances SET last_event_id = MAX(last_event_id, ?1) WHERE name = ?2 AND session_id = ?3", + rusqlite::params![flight.ack.last_event_id, flight.ack.instance_name, flight.session], + )?; + if changed != 1 { + bail!("canonical instance/session changed before delivery acknowledgement"); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn run( + launch: &Launch, + running: &Arc, + db: &mut HcomDb, + notify: &NotifyServer, + state: &DeliveryState, + process_id: &str, + current_name: &mut String, + config: &ToolConfig, + shared_name: &Option>>, + shared_status: &Option>>, + title_wake: &Option, + host_label: &mut super::host_label::HostLabel, + launch_outcome: &mut LaunchOutcome, +) { + let mut client: Option = None; + let mut session = String::new(); + let mut in_flight: Option = None; + let mut halted: Option = None; + let mut current_status = ST_LISTENING.to_string(); + let mut heartbeat = Instant::now(); + let mut connect_attempts = 0; + let mut retry_at = Instant::now(); + let mut connect_deadline = Instant::now() + SETUP_TIMEOUT; + while running.load(Ordering::Acquire) { + super::refresh_title_state(super::TitleRefresh { + db, + process_id, + current_name, + current_status: &mut current_status, + shared_name, + shared_status, + title_wake, + tool: &config.tool, + host_label, + }); + if client.is_some() && halted.is_none() { + super::drive_launch_outcome( + db, + state, + current_name, + ¤t_status, + config, + launch_outcome, + ); + } + if heartbeat.elapsed() >= Duration::from_secs(5) { + db.reconnect_if_stale(); + let _ = db.update_heartbeat(current_name); + let _ = db.register_notify_port(current_name, notify.port()); + let _ = db.register_inject_port(current_name, state.inject_port); + heartbeat = Instant::now(); + } + if let Some(error) = halted.as_deref() { + let _ = db.set_gate_status(current_name, "acp_unacknowledged", error); + notify.wait(POLL); + continue; + } + let instance = match db.get_instance_full(current_name) { + Ok(Some(instance)) => instance, + Ok(None) => break, + Err(error) => { + halted = Some(format!("canonical instance unreadable: {error}")); + continue; + } + }; + let Some(active_session) = instance.session_id.filter(|id| !id.is_empty()) else { + notify.wait(POLL); + continue; + }; + if session != active_session || client.is_none() { + if in_flight.is_some() { + halted = Some( + "session changed with an unacknowledged ACP prompt; automatic replay stopped" + .into(), + ); + continue; + } + if session != active_session { + connect_attempts = 0; + retry_at = Instant::now(); + connect_deadline = Instant::now() + SETUP_TIMEOUT; + } + if Instant::now() < retry_at { + notify.wait(POLL); + continue; + } + client.take(); + session = active_session; + connect_attempts += 1; + match Client::connect( + launch, + &session, + &instance.directory, + running, + connect_deadline, + ) { + Ok(connected) => client = Some(connected), + Err(error) => { + if !running.load(Ordering::Acquire) { + break; + } + if connect_attempts < 3 && Instant::now() < connect_deadline { + log_warn( + "native", + "grok.acp.connect_retry", + &format!("attempt={connect_attempts}: {error:#}; no prompt submitted"), + ); + retry_at = Instant::now() + Duration::from_secs(1); + continue; + } + halted = Some(format!( + "ACP connection failed: {error:#}; mailbox retained" + )); + if launch_outcome.is_pending() { + let detail = halted.as_deref().unwrap_or("ACP setup failed"); + let _ = db.set_status( + current_name, + crate::shared::ST_BLOCKED, + "launch_blocked", + ); + let _ = db.emit_launch_blocked_event( + current_name, + crate::shared::ST_BLOCKED, + "launch_blocked", + "grok_acp_connection", + detail, + ); + super::mark_launch_phase_complete( + state, + launch_outcome, + LaunchOutcome::Blocked, + ); + } + log_warn( + "native", + "grok.acp.blocked", + halted.as_deref().unwrap_or(""), + ); + continue; + } + } + } + let Some(client) = client.as_mut() else { + continue; + }; + while let Ok(event) = client.events.try_recv() { + match event { + Event::Closed(error) => { + halted = Some(format!( + "{error}; mailbox retained, automatic replay stopped" + )); + break; + } + Event::Response(value) => { + if let Some(flight) = in_flight.as_ref() + && let Some(result) = completed_response(&value, flight.request_id) + { + match result.and_then(|()| acknowledge(db, flight)) { + Ok(()) => { + log_info( + "native", + "grok.acp.ack", + &format!( + "instance={} session={} cursor={}", + flight.ack.instance_name, + flight.session, + flight.ack.last_event_id + ), + ); + in_flight = None; + } + Err(error) => { + halted = Some(format!( + "{error:#}; mailbox retained, automatic replay stopped" + )); + break; + } + } + } + } + event => observe_event(event), + } + } + if let Some(error) = halted.as_deref() { + log_warn("native", "grok.acp.blocked", error); + continue; + } + if in_flight.is_none() + && !matches!(current_status.as_str(), "stopped" | "inactive") + && let Some(prepared) = common::prepare_pending_messages(db, current_name) + { + let prompt_id = format!("hcom-{}", uuid::Uuid::new_v4()); + match client.send( + "session/prompt", + json!({ + "sessionId": session, + "prompt": [{"type": "text", "text": prepared.formatted}], + "_meta": {"promptId": prompt_id, "sendNow": false, "clientIdentifier": "hcom"} + }), + ) { + Ok(request_id) => { + log_info( + "native", + "grok.acp.enqueued", + &format!( + "instance={current_name} session={session} prompt={prompt_id} cursor={}", + prepared.ack.last_event_id + ), + ); + in_flight = Some(InFlight { + request_id, + session: session.clone(), + ack: prepared.ack, + }); + } + Err(error) => { + halted = Some(format!( + "ACP write failed: {error:#}; mailbox retained, automatic replay stopped" + )); + log_warn( + "native", + "grok.acp.blocked", + halted.as_deref().unwrap_or(""), + ); + } + } + } + notify.wait(POLL); + } + // Dropping the native stdio client does not close the TUI's session. + drop(client); +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn only_matching_end_turn_acknowledges_delivery() { + assert!( + completed_response(&json!({"id": 4, "result": {"stopReason": "end_turn"}}), 4) + .unwrap() + .is_ok() + ); + for result in [ + json!({"id": 4, "result": {"stopReason": "cancelled"}}), + json!({"id": 4, "result": {"stopReason": "max_tokens"}}), + json!({"id": 4, "result": {}}), + json!({"id": 4, "error": {"code": -32000, "message": "failed"}}), + ] { + assert!(completed_response(&result, 4).unwrap().is_err()); + } + assert!( + completed_response(&json!({"id": 5, "result": {"stopReason": "end_turn"}}), 4) + .is_none() + ); + } + + #[test] + fn queue_and_permission_events_are_not_receipts() { + let queue = json!({"method": "_x.ai/queue/changed", "params": {"sessionId": "s", "runningPromptId": "p", "entries": []}}); + assert!(matches!(classify_event(queue), Some(Event::Queue(_)))); + let permission = + json!({"id": "ask-1", "method": "session/request_permission", "params": {}}); + assert!(matches!( + classify_event(permission), + Some(Event::Interaction(_)) + )); + assert!(classify_event(json!({"method": "session/update", "params": {}})).is_none()); + } + + #[test] + #[serial] + fn setup_failure_retries_then_reports_blocked_without_a_receipt() { + let (_tmp, dir, _home, _guard) = crate::hooks::test_helpers::isolated_test_env(); + let mut db = HcomDb::open().unwrap(); + db.conn().execute( + "INSERT INTO instances (name, session_id, directory, tool, status, created_at, last_event_id) VALUES ('nova', 'session-1', ?1, 'grok', 'listening', 0, 0)", + rusqlite::params![dir.to_string_lossy()], + ).unwrap(); + let launch = + Launch::new(dir.join("missing-grok-binary").to_str().unwrap(), &[], &[]).unwrap(); + let running = Arc::new(AtomicBool::new(true)); + let phase = Arc::new(AtomicBool::new(true)); + let state = DeliveryState { + screen: Arc::new(RwLock::new(super::super::ScreenState::default())), + grok_unattended: true, + grok_acp: Some(launch.clone()), + launch_phase_active: phase.clone(), + inject_port: 0, + user_activity_cooldown_ms: 0, + }; + let stop_running = running.clone(); + let stop_phase = phase.clone(); + let stopper = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(10); + while stop_phase.load(Ordering::Acquire) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + stop_running.store(false, Ordering::Release); + }); + let mut outcome = LaunchOutcome::Pending; + let mut name = "nova".to_string(); + let started = Instant::now(); + run( + &launch, + &running, + &mut db, + &NotifyServer::new().unwrap(), + &state, + "", + &mut name, + &ToolConfig::for_tool(crate::tool::Tool::Grok), + &None, + &None, + &None, + &mut super::super::host_label::HostLabel::resolve(), + &mut outcome, + ); + stopper.join().unwrap(); + assert_eq!(outcome, LaunchOutcome::Blocked); + assert!(!phase.load(Ordering::Acquire)); + assert!( + started.elapsed() >= Duration::from_secs(2), + "setup must receive its bounded retries" + ); + assert_eq!(db.get_cursor("nova"), 0); + } + + #[test] + fn silent_peer_uses_one_deadline_across_setup_requests() { + #[cfg(windows)] + let mut command = { + use std::os::windows::process::CommandExt; + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/Q"]).creation_flags(0x08000000); + command + }; + #[cfg(not(windows))] + let mut command = Command::new("cat"); + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let input = child.stdin.take().unwrap(); + // Keep the channel open but deliver no RPC response, as with a hung peer. + let (_sender, events) = mpsc::channel(); + let mut client = Client { + child, + input, + events, + next_id: 0, + }; + let running = AtomicBool::new(true); + let deadline = Instant::now() + Duration::from_millis(100); + let error = client + .request("initialize", json!({}), &running, deadline) + .unwrap_err(); + assert!(error.to_string().contains("timed out")); + let sent = client.next_id; + assert!( + client + .request("authenticate", json!({}), &running, deadline) + .is_err() + ); + assert_eq!( + client.next_id, sent, + "later setup steps must not reset an expired deadline" + ); + } + + #[test] + fn launch_preserves_prefix_and_rejects_competing_leader() { + let launch = Launch::new("grok", &["prefix".into()], &["--resume", "session"]).unwrap(); + assert_eq!(launch.prefix, ["prefix"]); + assert_eq!(launch.tui_args()[0], "--leader"); + for flag in [ + "--leader", + "--no-leader", + "--leader-socket", + "--leader-socket=other", + ] { + assert!(Launch::new("grok", &[], &[flag]).is_err()); + } + for args in [ + vec!["--allow", "Bash"], + vec!["--deny=bash"], + vec!["--allowedTools", "Read(*)"], + vec!["--disallowedTools", "Bash(*)"], + vec!["--disable-web-search"], + ] { + assert!(Launch::validate_args(&args).is_ok()); + assert!(!Launch::policy_args(&args).unwrap().is_empty()); + } + assert!(Launch::policy_args(&["--no-subagents"]).unwrap().is_empty()); + let restricted = Launch::new("grok", &[], &["--no-subagents"]).unwrap(); + assert!( + restricted + .child_env() + .contains(&("GROK_SUBAGENTS".into(), "0".into())) + ); + } + + #[test] + fn policy_probe_is_required_only_for_inherited_cli_restrictions() { + let missing = || Command::new("hcom-test-missing-grok-policy-binary"); + assert!(Launch::check_policy_support(missing(), &["--no-subagents"]).is_ok()); + assert!(Launch::check_policy_support(missing(), &["--deny", "Bash"]).is_err()); + } + + #[test] + fn policy_projection_preserves_values_and_stops_at_prompt_marker() { + let args = [ + "--resume", + "session", + "--allow", + "Bash(Write-Output *)", + "--deny=Read(secret*)", + "--disable-web-search", + "--", + "--deny", + "--leader", + "--no-subagents", + ]; + assert_eq!( + Launch::policy_args(&args).unwrap(), + [ + "--allow", + "Bash(Write-Output *)", + "--deny=Read(secret*)", + "--disable-web-search" + ] + ); + assert!(Launch::validate_args(&args).is_ok()); + let literal = Launch::new( + "missing-grok-is-not-run", + &[], + &["--", "--deny", "--leader", "--no-subagents"], + ) + .unwrap(); + assert!(literal.policy_args.is_empty()); + assert!(!literal.no_subagents); + } + + #[test] + #[serial] + fn receipt_is_monotonic_session_scoped_and_preserves_hook_status() { + let (_tmp, _dir, _home, _guard) = crate::hooks::test_helpers::isolated_test_env(); + let db = HcomDb::open().unwrap(); + db.conn().execute( + "INSERT INTO instances (name, session_id, tool, status, status_context, last_event_id, created_at) VALUES ('nova', 'session-1', 'grok', 'active', 'new-human-prompt', 9, 0)", + [], + ).unwrap(); + let mut flight = InFlight { + request_id: 4, + session: "session-1".into(), + ack: DeliveryAck { + instance_name: "nova".into(), + last_event_id: 7, + status_context: "deliver:sender".into(), + msg_ts: String::new(), + mark_announced: false, + }, + }; + acknowledge(&db, &flight).unwrap(); + assert_eq!(db.get_cursor("nova"), 9); + assert_eq!( + db.get_status("nova").unwrap().unwrap(), + ("active".into(), "new-human-prompt".into()) + ); + flight.ack.last_event_id = 12; + flight.session = "old-session".into(); + assert!(acknowledge(&db, &flight).is_err()); + assert_eq!(db.get_cursor("nova"), 9); + flight.session = "session-1".into(); + acknowledge(&db, &flight).unwrap(); + assert_eq!(db.get_cursor("nova"), 12); + } +} diff --git a/src/hooks/grok.rs b/src/hooks/grok.rs index 86ce1e26..8ae74ae9 100644 --- a/src/hooks/grok.rs +++ b/src/hooks/grok.rs @@ -496,6 +496,7 @@ fn handle_stop( db: &HcomDb, ctx: &HcomContext, payload: &HookPayload, + acp_enabled: bool, ) -> (Value, Option) { let Some(instance) = resolved_instance(db, ctx, payload) else { return (json!({}), None); @@ -503,6 +504,23 @@ fn handle_stop( lifecycle::set_status(db, &instance.name, ST_LISTENING, "", Default::default()); common::notify_hook_instance_with_db(db, &instance.name); + // Managed ACP sessions have one mailbox consumer. Hooks still publish + // lifecycle state, but must not race the queued prompt's deferred receipt. + log::log_info( + "hooks", + "grok.stop.owner", + &format!( + "acp={} tcp={} process={} executable={}", + acp_enabled, + instance.tcp_mode, + ctx.process_id.as_deref().unwrap_or(""), + std::env::current_exe().unwrap_or_default().display() + ), + ); + if acp_enabled && instance.tcp_mode != 0 { + return (json!({}), None); + } + if !is_genuine_end_turn_stop(payload) { return (json!({}), None); } @@ -585,9 +603,19 @@ pub fn dispatch_grok_hook(hook_name: &str) -> i32 { "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-stop" => handle_stop( + &db, + &ctx, + &payload, + std::env::var("HCOM_GROK_ACP").as_deref() == Ok("1"), + ), "grok-subagentstart" => (handle_subagentstart(&db, &ctx, &payload), None), - "grok-subagentstop" => handle_stop(&db, &ctx, &payload), + "grok-subagentstop" => handle_stop( + &db, + &ctx, + &payload, + std::env::var("HCOM_GROK_ACP").as_deref() == Ok("1"), + ), "grok-sessionend" => (handle_sessionend(&db, &ctx, &payload), None), _ => (json!({}), None), }, @@ -799,6 +827,47 @@ mod tests { assert!(!is_hcom_grok_command("./custom-stop.sh")); } + #[test] + #[serial] + fn acp_stop_keeps_mail_when_only_session_binding_survives() { + let (_tmp, hcom_dir, _home, _guard) = crate::hooks::test_helpers::isolated_test_env(); + let db = HcomDb::open().unwrap(); + db.conn().execute( + "INSERT INTO instances (name, session_id, tool, status, created_at, last_event_id, tcp_mode) VALUES ('nova', 'sess-1', 'grok', 'listening', 0, 0, 1)", + [], + ).unwrap(); + db.set_session_binding("sess-1", "nova").unwrap(); + db.conn().execute( + "INSERT INTO events (type, timestamp, instance, data) VALUES ('message', '2026-01-01T00:00:01Z', 'luna', ?1)", + rusqlite::params![json!({"from": "luna", "text": "ACP owns this mail", "scope": "broadcast"}).to_string()], + ).unwrap(); + let env = std::collections::HashMap::from([ + ("HCOM_PROCESS_ID".into(), "missing-process-binding".into()), + ("HCOM_LAUNCHED".into(), "1".into()), + ("HCOM_DIR".into(), hcom_dir.to_string_lossy().into_owned()), + ]); + let ctx = HcomContext::from_env(&env, PathBuf::from(".")); + let payload = HookPayload::from_grok( + "grok-stop", + json!({"session_id": "sess-1", "reason": "end_turn"}), + ); + let (out, ack) = handle_stop(&db, &ctx, &payload, true); + assert_eq!(out, json!({})); + assert!(ack.is_none()); + assert_eq!(db.get_cursor("nova"), 0); + assert!(db.has_pending("nova")); + + db.update_tcp_mode("nova", false).unwrap(); + let (out, ack) = handle_stop(&db, &ctx, &payload, true); + assert!( + out["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap() + .contains("ACP owns this mail") + ); + assert!(ack.is_some(), "hook-only sessions retain legacy delivery"); + } + #[test] #[serial] fn stop_output_uses_additional_context_not_followup() { @@ -835,7 +904,7 @@ mod tests { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), ); let payload = HookPayload::from_grok("grok-stop", json!({ "reason": "end_turn" })); - let (out, ack) = handle_stop(&db, &ctx, &payload); + let (out, ack) = handle_stop(&db, &ctx, &payload, false); assert!( out.get("followup_message").is_none(), diff --git a/src/integration_spec.rs b/src/integration_spec.rs index bd5b7798..f456877a 100644 --- a/src/integration_spec.rs +++ b/src/integration_spec.rs @@ -1084,8 +1084,8 @@ pub static GROK: IntegrationSpec = IntegrationSpec { 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). + // No verified composer parser yet. Interactive idle wake stays gated; + // an explicit unattended headless worker may use hook idle status. ready_pattern: b"", pty: PtySpec { delivery_start_timeout_secs: 10, @@ -1101,12 +1101,12 @@ pub static GROK: IntegrationSpec = IntegrationSpec { 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. + // Grok's delivery gate checks draft/headless ownership explicitly. + // Keep this false: it also gates launch readiness, and no parser exists. require_prompt_empty: false, block_on_user_activity: true, - // Composer scrape is None; do not pretend we can see Grok approval UI. - block_on_approval: false, + // Honor any positive approval signal, even without a complete UI parser. + 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, diff --git a/src/launcher.rs b/src/launcher.rs index 44634dd0..9a03e72f 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -1768,6 +1768,13 @@ pub fn launch(db: &HcomDb, mut params: LaunchParams) -> Result { bail!("{}", validation_errors.join("\n")); } } + if normalized == LaunchTool::Grok { + let args: Vec<_> = params.args.iter().map(String::as_str).collect(); + crate::delivery::grok::Launch::check_policy_support( + crate::terminal::executable_command(tool_binary), + &args, + )?; + } // Load config before hook setup so auto_approve is authoritative for // wrapped launches as well as manual `hcom hooks add`. @@ -2578,7 +2585,14 @@ pub(crate) fn validate_tool_args(tool: &LaunchTool, args: &[String]) -> Vec crate::tools::copilot_preprocessing::validate_copilot_args(args), - LaunchTool::Grok => validate_rejected_args("Grok", "hcom grok", args, GROK_REJECTED_ARGS), + LaunchTool::Grok => { + let mut errors = validate_rejected_args("Grok", "hcom grok", args, GROK_REJECTED_ARGS); + let borrowed: Vec<_> = args.iter().map(String::as_str).collect(); + if let Err(error) = crate::delivery::grok::Launch::validate_args(&borrowed) { + errors.push(error.to_string()); + } + errors + } } } @@ -2814,6 +2828,17 @@ mod tests { assert!( validate_tool_args(&LaunchTool::Grok, &["--always-approve".to_string()]).is_empty() ); + assert_eq!( + validate_tool_args(&LaunchTool::Grok, &["--no-leader".into()]).len(), + 1 + ); + for flag in ["--deny=bash", "--disable-web-search"] { + assert!(validate_tool_args(&LaunchTool::Grok, &[flag.to_string()]).is_empty()); + } + assert!( + validate_tool_args(&LaunchTool::Grok, &["--allow".into(), "Bash".into()]).is_empty() + ); + assert!(validate_tool_args(&LaunchTool::Grok, &["--no-subagents".to_string()]).is_empty()); } #[test] diff --git a/src/main.rs b/src/main.rs index 832578eb..d66cdd8d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -160,9 +160,19 @@ pub fn run_pty(args: &[String]) -> Result<()> { command = resolved; extra_args = vec![]; } + let mut child_env = pty_child_env(); + let grok_acp = if target.known_tool() == Some(tool::Tool::Grok) && instance_name.is_some() { + let launch = delivery::grok::Launch::new(&command, &extra_args, &tool_args)?; + child_env.extend(launch.child_env()); + Some(launch) + } else { + None + }; + let leader_args = grok_acp.as_ref().map(|l| l.tui_args()).unwrap_or_default(); let full_args: Vec<&str> = extra_args .iter() .map(|s| s.as_str()) + .chain(leader_args.iter().map(String::as_str)) .chain(tool_args.iter().copied()) .collect(); @@ -175,7 +185,8 @@ pub fn run_pty(args: &[String]) -> Result<()> { ready_pattern, instance_name, target, - env_vars: pty_child_env(), + env_vars: child_env, + grok_acp, }, ) { Ok(proxy) => proxy, diff --git a/src/pty/inject.rs b/src/pty/inject.rs index 14cdebe1..bd9eb43e 100644 --- a/src/pty/inject.rs +++ b/src/pty/inject.rs @@ -25,9 +25,11 @@ pub struct QueryClient { pub command: QueryCommand, } -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum QueryCommand { Screen, + GrokWake, + GrokEnter, Unknown, } @@ -130,6 +132,8 @@ impl InjectServer { let (stream, _) = self.clients.remove(index); let command = match cmd { "SCREEN" => QueryCommand::Screen, + "GROK_WAKE" => QueryCommand::GrokWake, + "GROK_ENTER" => QueryCommand::GrokEnter, _ => QueryCommand::Unknown, }; return Ok(InjectResult::Query(QueryClient { stream, command })); diff --git a/src/pty/mod.rs b/src/pty/mod.rs index 7ed0d925..82716cd9 100644 --- a/src/pty/mod.rs +++ b/src/pty/mod.rs @@ -587,6 +587,7 @@ pub struct ProxyConfig { pub target: PtyTarget, /// Extra environment variables to set in the child process pub env_vars: Vec<(String, String)>, + pub grok_acp: Option, } impl Default for ProxyConfig { @@ -596,6 +597,7 @@ impl Default for ProxyConfig { instance_name: None, target: PtyTarget::Known(Tool::Claude), env_vars: vec![], + grok_acp: None, } } } @@ -938,6 +940,7 @@ impl Proxy { self.current_name.clone(), self.current_status.clone(), Some(title_wake_callback(self.title_notify_write.clone())), + self.config.grok_acp.clone(), )? { shared::DeliveryStart::Started(h) => { self.delivery_handle = Some(h); @@ -1107,6 +1110,7 @@ impl Proxy { self.current_name.clone(), self.current_status.clone(), Some(title_wake_callback(self.title_notify_write.clone())), + self.config.grok_acp.clone(), )? { shared::DeliveryStart::Started(h) => { self.delivery_handle = Some(h); @@ -1231,6 +1235,11 @@ impl Proxy { { match self.inject_server.read_client(i)? { inject::InjectResult::Inject(text) => { + shared::note_external_grok_input( + &self.config.target, + &self.delivery_state, + &text, + ); write_all(&self.pty_master, text.as_bytes())?; // Injected keystrokes reach the PTY master directly and // bypass the interactive stdin handler. When one answers a @@ -1254,6 +1263,22 @@ impl Proxy { ); client.respond(&dump); } + inject::QueryCommand::GrokWake | inject::QueryCommand::GrokEnter => { + let enter = + matches!(client.command, inject::QueryCommand::GrokEnter); + let sent = shared::write_grok_wake( + &self.config.target, + &self.delivery_state, + shared::grok_unattended_surface(&self.config.target), + enter, + |bytes| write_all(&self.pty_master, bytes).is_ok(), + ); + client.respond(if sent { + "ok\n" + } else { + "error: Grok prompt is not owned\n" + }); + } inject::QueryCommand::Unknown => { client.respond("error: unknown command\n"); } diff --git a/src/pty/shared.rs b/src/pty/shared.rs index 6805e906..2ce83b83 100644 --- a/src/pty/shared.rs +++ b/src/pty/shared.rs @@ -8,6 +8,7 @@ //! finalization. The bodies are byte-for-byte the Unix originals apart from the //! `self.X` → parameter substitution; the Unix correctness rests on that. +use std::io::IsTerminal; use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; use std::sync::mpsc; use std::sync::{Arc, RwLock}; @@ -158,6 +159,7 @@ pub(super) fn note_user_keystroke( let mut approval_cleared = false; if let Ok(mut state) = screen_state.write() { state.last_user_input = Instant::now(); + state.user_input_seen = true; if !cursor_scrape { approval_cleared = state.approval; state.approval = false; @@ -169,6 +171,60 @@ pub(super) fn note_user_keystroke( approval_cleared } +pub(super) fn grok_unattended_surface(target: &PtyTarget) -> bool { + matches!(target.known_tool(), Some(Tool::Grok)) + && crate::shared::context::HcomContext::from_os().is_background + && !std::io::stdin().is_terminal() +} + +/// Raw TCP input includes `hcom term inject`; it is never an internal wake. +pub(super) fn note_external_grok_input( + target: &PtyTarget, + state: &Arc>, + text: &str, +) { + if matches!(target.known_tool(), Some(Tool::Grok)) + && !text.is_empty() + && let Ok(mut screen) = state.write() + { + screen.user_input_seen = true; + screen.last_user_input = Instant::now(); + } +} + +/// Caller serializes this write with stdin/raw injections (the Unix poll loop +/// or Windows writer mutex). Keep ownership stable until the bytes are written. +pub(super) fn write_grok_wake( + target: &PtyTarget, + state: &Arc>, + unattended: bool, + enter: bool, + write: impl FnOnce(&[u8]) -> bool, +) -> bool { + if !matches!(target.known_tool(), Some(Tool::Grok)) { + return false; + } + let Ok(screen) = state.read() else { + return false; + }; + let expected = if enter { + crate::delivery::GROK_WAKE_TRIGGER + } else { + "" + }; + if screen.approval + || screen.last_user_input.elapsed().as_millis() < USER_ACTIVITY_COOLDOWN_MS as u128 + || !crate::delivery::grok_prompt_owned(&screen, unattended, expected) + { + return false; + } + write(if enter { + b"\r" + } else { + crate::delivery::GROK_WAKE_TRIGGER.as_bytes() + }) +} + /// Publish PTY approval edges independently of the delivery queue. /// /// Approval is agent state: `hcom list` must report it even when no message @@ -320,6 +376,7 @@ pub(super) fn start_delivery_thread( current_name: Arc>, current_status: Arc>, title_wake: Option, + grok_acp: Option, ) -> Result { let instance_name = match instance_name_cfg { Some(name) => name.to_string(), @@ -412,6 +469,8 @@ pub(super) fn start_delivery_thread( // Create delivery state wrapper let state = DeliveryState { screen: delivery_state, + grok_unattended: grok_unattended_surface(&target), + grok_acp, launch_phase_active, inject_port, user_activity_cooldown_ms: USER_ACTIVITY_COOLDOWN_MS, @@ -1295,6 +1354,111 @@ mod tests { assert_eq!(esc, format!("\x1b]1;{}\x07\x1b]2;{}\x07", title, title)); } + #[test] + fn grok_wake_wire_protocol_preserves_workers_and_blocks_operator_drafts() { + use crate::pty::inject::{InjectResult, InjectServer, QueryCommand}; + use std::io::Write; + use std::net::TcpStream; + use std::sync::Mutex; + + for takeover in [false, true] { + let mut server = InjectServer::new().unwrap(); + let port = server.port(); + let captured = Arc::new(Mutex::new(Vec::new())); + let output = captured.clone(); + let worker = std::thread::spawn(move || { + let target = PtyTarget::Known(Tool::Grok); + let state = Arc::new(RwLock::new(ScreenState { + last_user_input: Instant::now() - Duration::from_secs(60), + ..ScreenState::default() + })); + let expected = if takeover { 3 } else { 2 }; + let mut received = 0; + let deadline = Instant::now() + Duration::from_secs(5); + while received < expected && Instant::now() < deadline { + while server.accept().unwrap() {} + match server.read_client(0).unwrap() { + InjectResult::Query(client) => { + let enter = match client.command { + QueryCommand::GrokWake => false, + QueryCommand::GrokEnter => true, + _ => panic!("unexpected command"), + }; + let sent = write_grok_wake(&target, &state, true, enter, |bytes| { + output.lock().unwrap().extend_from_slice(bytes); + true + }); + client.respond(if sent { "ok\n" } else { "error: not owned\n" }); + received += 1; + } + InjectResult::Inject(text) => { + note_external_grok_input(&target, &state, &text); + output.lock().unwrap().extend_from_slice(text.as_bytes()); + // Model an old, untouched draft: expiry cannot rearm wake. + state.write().unwrap().last_user_input = + Instant::now() - Duration::from_secs(60); + received += 1; + } + InjectResult::Pending => std::thread::sleep(Duration::from_millis(1)), + } + } + assert_eq!(received, expected); + }); + assert!(crate::delivery::inject_grok_command(port, false)); + if takeover { + let mut raw = TcpStream::connect(("127.0.0.1", port)).unwrap(); + raw.write_all(b"private draft").unwrap(); + } + assert_eq!(crate::delivery::inject_grok_command(port, true), !takeover); + worker.join().unwrap(); + assert_eq!( + captured.lock().unwrap().as_slice(), + if takeover { + b"hcom: wakeprivate draft".as_slice() + } else { + b"hcom: wake\r".as_slice() + } + ); + } + } + + #[test] + fn grok_write_boundary_rejects_unknown_interactive_prompt_and_other_tools() { + let state = Arc::new(RwLock::new(ScreenState { + last_user_input: Instant::now() - Duration::from_secs(60), + ..ScreenState::default() + })); + for enter in [false, true] { + assert!(!write_grok_wake( + &PtyTarget::Known(Tool::Grok), + &state, + false, + enter, + |_| panic!("must not write") + )); + assert!(!write_grok_wake( + &PtyTarget::Known(Tool::Claude), + &state, + true, + enter, + |_| panic!("must not write") + )); + } + } + + #[test] + fn grok_user_input_is_sticky_after_typing_cooldown() { + let target = PtyTarget::Known(Tool::Grok); + let screen = Arc::new(RwLock::new(ScreenState::default())); + assert!(!screen.read().unwrap().user_input_seen); + note_user_keystroke(&target, &screen, &|_| {}); + { + let mut state = screen.write().unwrap(); + state.last_user_input = Instant::now() - Duration::from_secs(60); + } + assert!(screen.read().unwrap().user_input_seen); + } + #[test] fn note_user_keystroke_cursor_is_noop_and_returns_false() { let target = PtyTarget::AdhocCommand("cursor".to_string()); diff --git a/src/pty/win.rs b/src/pty/win.rs index a6685b9b..076d470e 100644 --- a/src/pty/win.rs +++ b/src/pty/win.rs @@ -328,6 +328,7 @@ impl Proxy { let launch_phase = self.launch_phase_active.clone(); let target = self.config.target.clone(); let instance = self.config.instance_name.clone(); + let grok_acp = self.config.grok_acp.clone(); let current_name = self.current_name.clone(); let current_status = self.current_status.clone(); let notify_port = self.notify_port.clone(); @@ -356,6 +357,7 @@ impl Proxy { current_name.clone(), current_status.clone(), None, + grok_acp.clone(), ) { Ok(shared::DeliveryStart::Started(h)) => { *delivery_handle.lock().unwrap_or_else(|e| e.into_inner()) = Some(h); @@ -754,30 +756,30 @@ impl Proxy { Ok(0) => break, Ok(n) => { if let Ok(mut w) = writer.lock() { + if n > 0 { + // A genuine keystroke answering a title-detected + // approval clears it immediately. Record the cleared + // edge against shared state; the reader thread owns + // the tracker, so request a tracker-clear via the + // atomic it consumes — but ONLY when an approval was + // actually standing. `clear_approval()` wipes the OSC + // scrape buffer, so requesting it on every keystroke + // would let a routine keypress race out an approval + // edge arriving in the same window. + let publish = |a: bool| { + shared::publish_approval_status( + a, + instance.as_deref(), + ¤t_status, + ) + }; + if shared::note_user_keystroke(&target, &screen_state, &publish) { + approval_clear_requested.store(true, Ordering::Release); + } + } let _ = w.write_all(&buf[..n]); let _ = w.flush(); } - if n > 0 { - // A genuine keystroke answering a title-detected - // approval clears it immediately. Record the cleared - // edge against shared state; the reader thread owns - // the tracker, so request a tracker-clear via the - // atomic it consumes — but ONLY when an approval was - // actually standing. `clear_approval()` wipes the OSC - // scrape buffer, so requesting it on every keystroke - // would let a routine keypress race out an approval - // edge arriving in the same window. - let publish = |a: bool| { - shared::publish_approval_status( - a, - instance.as_deref(), - ¤t_status, - ) - }; - if shared::note_user_keystroke(&target, &screen_state, &publish) { - approval_clear_requested.store(true, Ordering::Release); - } - } } Err(_) => break, } @@ -815,6 +817,7 @@ impl Proxy { let completed = match inject_server.read_client(index) { Ok(InjectResult::Inject(text)) => { if let Ok(mut w) = writer.lock() { + shared::note_external_grok_input(&target, &screen_state, &text); let _ = w.write_all(text.as_bytes()); let _ = w.flush(); } @@ -851,6 +854,27 @@ impl Proxy { .unwrap_or_default(); q.respond(&dump); } + QueryCommand::GrokWake | QueryCommand::GrokEnter => { + let enter = matches!(q.command, QueryCommand::GrokEnter); + let sent = if let Ok(mut w) = writer.lock() { + shared::write_grok_wake( + &target, + &screen_state, + shared::grok_unattended_surface(&target), + enter, + |bytes| { + w.write_all(bytes).and_then(|_| w.flush()).is_ok() + }, + ) + } else { + false + }; + q.respond(if sent { + "ok\n" + } else { + "error: Grok prompt is not owned\n" + }); + } QueryCommand::Unknown => q.respond("error: unknown command\n"), } true