diff --git a/README.md b/README.md index faef9a0d..32b258a8 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,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 @@ -270,7 +271,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 @@ -334,7 +335,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/skills/hcom-agent-messaging/references/cross-tool.md b/skills/hcom-agent-messaging/references/cross-tool.md index 07c44065..f08a32f0 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,19 @@ 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**: 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`). + ## 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/bootstrap.rs b/src/bootstrap.rs index 627e4899..e4b1e32c 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 + +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. + +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 999a8df5..5adee06c 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -220,6 +220,7 @@ fn toml_path_for_key(field_name: &str) -> Option<&'static str> { "cursor_args" => Some("launch.cursor.args"), "kimi_args" => Some("launch.kimi.args"), "copilot_args" => Some("launch.copilot.args"), + "grok_args" => Some("launch.grok.args"), "relay" => Some("relay.url"), "relay_id" => Some("relay.id"), "relay_token" => Some("relay.token"), @@ -1499,7 +1500,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: @@ -1637,6 +1638,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 c23c6b52..519c1e29 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"), ]; @@ -447,7 +447,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:"), @@ -481,7 +481,7 @@ const CONFIG_HELP: &[HelpEntry] = &[ "Subagent keep-alive seconds after task", ), ( - " claude_args / gemini_args / codex_args / opencode_args / kilo_args / pi_args / omp_args / cursor_args / kimi_args / copilot_args", + " claude_args / gemini_args / codex_args / opencode_args / kilo_args / pi_args / omp_args / cursor_args / kimi_args / copilot_args / grok_args", "", ), (" auto_approve", "Auto-approve safe hcom commands"), @@ -868,6 +868,8 @@ pub const COMMAND_NAMES: &[&str] = &[ "cursor-agent", "kimi", "copilot", + "grok", + "grok-build", ]; fn resumable_tool_names() -> String { @@ -1257,15 +1259,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 559e0046..079491b7 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 { @@ -530,6 +531,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), @@ -562,6 +564,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 194bade5..ac669df4 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,114 @@ fn merge_resume_args(tool: &str, original: &[String], resume: &[String]) -> Vec< } } +/// Merge grok original launch args with resume args. +/// +/// 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", + "-m", + "--cwd", + "--rules", + "--agent", + "--permission-mode", + "--reasoning-effort", + "--effort", + "--max-turns", + "--output-format", + "--disallowed-tools", + "--tools", + "--allow", + "--deny", + "--sandbox", + "--leader-socket", + "--debug-file", + "--system-prompt-override", + ]; + const DROP_WITH_VALUE: &[&str] = &[ + "--resume", + "-r", + "--session-id", + "-s", + "--single", + "-p", + "--prompt-file", + "--prompt-json", + "--worktree", + "-w", + "--worktree-ref", + "--ref", + ]; + 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 @@ -3567,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/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 86a94eda..b67571c3 100644 --- a/src/commands/transcript.rs +++ b/src/commands/transcript.rs @@ -1570,6 +1570,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 4f307f22..0ff6c5c5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -140,6 +140,7 @@ const TOML_KEY_MAP: &[(&str, &str)] = &[ ("cursor_args", "launch.cursor.args"), ("kimi_args", "launch.kimi.args"), ("copilot_args", "launch.copilot.args"), + ("grok_args", "launch.grok.args"), ("relay", "relay.url"), ("relay_id", "relay.id"), ("relay_token", "relay.token"), @@ -173,6 +174,7 @@ const FIELD_TO_ENV: &[(&str, &str)] = &[ ("cursor_args", "HCOM_CURSOR_ARGS"), ("kimi_args", "HCOM_KIMI_ARGS"), ("copilot_args", "HCOM_COPILOT_ARGS"), + ("grok_args", "HCOM_GROK_ARGS"), ("relay", "HCOM_RELAY"), ("relay_id", "HCOM_RELAY_ID"), ("relay_token", "HCOM_RELAY_TOKEN"), @@ -287,6 +289,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, @@ -325,6 +328,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(), @@ -445,6 +449,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) @@ -509,6 +514,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()), @@ -555,6 +561,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" { @@ -686,6 +693,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 45b4a5da..0d064d30 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}; @@ -707,12 +708,27 @@ 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", } } +/// Short PTY sentinel for Grok idle wake — no angle brackets (WT paste hazard). +/// +/// Real message bodies are **not** pasted into the composer. They are delivered +/// from native `grok-stop` via `hookSpecificOutput.additionalContext` after a +/// genuine end-of-turn Stop (observe-only hooks discard stdout). +pub(crate) const GROK_WAKE_TRIGGER: &str = "hcom: wake"; + +/// PTY inject text for Grok: always the short wake sentinel. +pub(crate) fn build_grok_inject_text(_db: &HcomDb, _recipient: &str) -> String { + GROK_WAKE_TRIGGER.to_string() +} + /// Build PTY wake text for tools whose delivery path is not human-visible. /// /// Claude and Codex inject the plain `` trigger because their hooks already @@ -904,6 +920,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`). @@ -978,6 +997,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 @@ -1016,6 +1037,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, @@ -1140,6 +1162,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, @@ -1417,6 +1449,56 @@ pub(crate) fn inject_text(port: u16, text: &str) -> bool { } } +/// 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 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. + // Primary success path is ST_ACTIVE + prompt/trigger from UPS. + return false; + } + matches!(context, "prompt" | "trigger") + || context.starts_with("tool:") + || 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)) { @@ -1457,6 +1539,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, @@ -1666,7 +1765,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) ) { @@ -1770,6 +1885,12 @@ 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; + 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(); @@ -1854,7 +1975,42 @@ 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; + grok_awaiting_stop_since = None; + } + 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", + &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", @@ -1885,6 +2041,8 @@ pub fn run_delivery_loop( "delivery.no_pending", &format!("No pending messages for {}", current_name), ); + grok_awaiting_stop_at = None; + grok_awaiting_stop_since = None; delivery_state = State::Idle; attempt = 0; continue; @@ -1927,13 +2085,23 @@ pub fn run_delivery_loop( let cols = state.screen.read().map(|s| s.cols).unwrap_or(80); let input_box_width = (cols as usize).saturating_sub(15).max(10); let text = match parsed_tool { + // Grok: short wake sentinel only; body via Stop additionalContext. + Some(Tool::Grok) => build_grok_inject_text(db, ¤t_name), Some(Tool::Claude) | Some(Tool::Codex) | Some(Tool::Cursor) | Some(Tool::Kimi) | Some(Tool::Copilot) | Some(Tool::Pi) | Some(Tool::Omp) => "".to_string(), _ => build_wake_inject_text(db, ¤t_name, input_box_width), }; - if inject_text(state.inject_port, &text) { + // No Ctrl-U clear for Grok: cannot observe composer; risk + // partial draft deletion. Sentinel is short and idle-gated. + let inject_ok = if config.tool == "grok" { + inject_grok_command(state.inject_port, false) + } else { + inject_text(state.inject_port, &text) + }; + + if inject_ok { log_info( "native", "delivery.injected", @@ -1941,7 +2109,7 @@ pub fn run_delivery_loop( "Injected '{}' (len={}, inject_attempt={})", truncate_chars(&text, 40), text.len(), - inject_attempt + inject_attempt, ), ); injected_text = text; @@ -2114,6 +2282,142 @@ pub fn run_delivery_loop( State::WaitTextRender => { let elapsed = phase_started_at.elapsed(); + // 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. + if config.tool == "grok" { + if enter_attempt > 0 { + let still_pending = db.has_pending(¤t_name); + let turn_started = match db.get_status(¤t_name) { + Ok(Some((status, ctx))) => grok_turn_started(&status, &ctx), + _ => false, + }; + // Success for PTY wake: turn started (UPS trigger/prompt) + // or Stop already acked (pending cleared). Do NOT ack here. + if turn_started || !still_pending { + log_info( + "native", + "delivery.grok_wake_done", + &format!( + "Grok wake complete (enter_attempt={enter_attempt}, turn_started={turn_started}, pending={still_pending})" + ), + ); + inject_attempt = 0; + attempt = 0; + // If still pending, Stop will deliver 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)); + 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_UPS_WAIT { + std::thread::sleep(Duration::from_millis(50)); + continue; + } + + 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); + if !state.grok_can_submit(&injected_text) { + if elapsed > PHASE1_TIMEOUT { + log_warn( + "native", + "delivery.grok_enter_retry_blocked", + &format!( + "Grok Enter retry lacks submit authority (user_active={user_active}, approval={approval}); pending kept" + ), + ); + delivery_state = State::Pending; + inject_attempt += 1; + attempt += 1; + } else { + std::thread::sleep(Duration::from_millis(50)); + } + continue; + } + log_info( + "native", + "delivery.grok_retry_enter", + &format!( + "Grok still pending after {:?}; re-Enter sentinel (attempt={}/{})", + elapsed, + enter_attempt + 1, + GROK_MAX_ENTER_ATTEMPTS, + ), + ); + // Single Enter only — double Enter can queue two wakes. + inject_grok_command(state.inject_port, true); + enter_attempt += 1; + phase_started_at = Instant::now(); + continue; + } + + log_warn( + "native", + "delivery.grok_wake_unconfirmed", + &format!( + "Grok wake unconfirmed after {GROK_MAX_ENTER_ATTEMPTS} Enters; leaving pending (no force-ack)" + ), + ); + // Leave messages pending for a later idle cycle. + delivery_state = State::Pending; + inject_attempt += 1; + attempt += 1; + phase_started_at = Instant::now(); + continue; + } + + // First Enter after short settle for tiny sentinel. + let settle = Duration::from_millis(400); + if elapsed < settle { + std::thread::sleep(Duration::from_millis(25)); + continue; + } + let user_active = state.is_user_active(); + let approval = state.screen.read().map(|s| s.approval).unwrap_or(false); + if !state.grok_can_submit(&injected_text) { + if elapsed > PHASE1_TIMEOUT { + log_warn( + "native", + "delivery.grok_enter_blocked", + &format!( + "Grok Enter lacks submit authority (user_active={user_active}, approval={approval}); pending kept" + ), + ); + delivery_state = State::Pending; + inject_attempt += 1; + attempt += 1; + } else { + std::thread::sleep(Duration::from_millis(50)); + } + continue; + } + log_info( + "native", + "delivery.grok_force_enter", + &format!( + "Forcing Enter after wake sentinel (bytes={}) — body via Stop additionalContext", + injected_text.len(), + ), + ); + inject_grok_command(state.inject_port, true); + enter_attempt = 1; + phase_started_at = Instant::now(); + continue; + } + // Inspect the latest screen before applying the deadline. This // avoids rejecting a render that completed at the timeout edge. let screen = state.screen.read().unwrap(); @@ -2644,7 +2948,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, @@ -2660,6 +2966,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, @@ -2785,6 +3092,122 @@ mod tests { // ---- phase-1 ownership tests ---- + #[test] + fn grok_turn_started_ignores_premature_deliver_ack() { + // commit_delivery_ack sets active + deliver:sender — must NOT count. + assert!(!grok_turn_started(ST_ACTIVE, "deliver:vomu")); + assert!(!grok_turn_started(ST_LISTENING, "")); + // Real UPS contexts. + assert!(grok_turn_started(ST_ACTIVE, "prompt")); + assert!(grok_turn_started(ST_ACTIVE, "trigger")); + assert!(grok_turn_started(ST_ACTIVE, "tool:Bash")); + } + + #[test] + fn grok_wake_trigger_has_no_angle_brackets() { + assert!(!GROK_WAKE_TRIGGER.contains('<')); + } + + #[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"); + } + + #[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/claude.rs b/src/hooks/claude.rs index 92237bf8..13ea7e93 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -286,6 +286,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); @@ -1629,6 +1636,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") @@ -1730,6 +1743,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); @@ -1804,13 +1822,18 @@ fn handle_poll( "hooks", "stop.enter", &format!( - "instance={} is_headless={} pty_mode={}", - instance_name, ctx.is_background, ctx.is_pty_mode + "instance={} is_headless={} pty_mode={} grok_host={}", + instance_name, + ctx.is_background, + ctx.is_pty_mode, + common::is_grok_host() ), ); - // PTY mode: exit immediately, PTY wrapper handles injection - if ctx.is_pty_mode { + // Grok loads Claude-compat Stop hooks, but native `grok-stop` is the sole + // delivery owner (StopHookJson.additionalContext). Claude-compat only + // flips listening + notifies — no followup_message (not in Grok's schema). + if common::is_grok_host() || ctx.is_pty_mode { lifecycle::set_status(db, instance_name, ST_LISTENING, "", Default::default()); common::notify_hook_instance_with_db(db, instance_name); return (0, String::new(), None); @@ -1844,6 +1867,7 @@ fn handle_poll( // Always exit 0: Claude ignores stdout JSON on exit 2 for Stop, so a // delivered message must go out as exit 0 + decision:block, with the ack // committed by write_hook_output only after that stdout is flushed. + // (Grok hosts already returned above — native grok-stop owns delivery.) (0, stdout, result.ack) } @@ -1877,7 +1901,14 @@ fn handle_userpromptsubmit( paths::increment_flag_counter("instance_count"); } - // PTY mode: deliver messages + // Grok host: UPS is observe-only (stdout discarded). Status is handled by + // native grok-userpromptsubmit; leave pending for grok-stop additionalContext. + if common::is_grok_host() { + lifecycle::set_status(db, instance_name, ST_ACTIVE, "prompt", Default::default()); + return (0, String::new(), None); + } + + // PTY mode: deliver pending via Claude-compatible UPS payload. if ctx.is_pty_mode && let Some(prepared) = common::prepare_pending_messages(db, instance_name) { @@ -4968,6 +4999,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 dda2a71a..b3fdcb4e 100644 --- a/src/hooks/common.rs +++ b/src/hooks/common.rs @@ -71,6 +71,62 @@ pub(crate) const SAFE_HCOM_COMMANDS: &[&str] = &[ "--new-terminal", ]; +/// True when this process is a Grok **hook** (Claude-compat or native). +/// +/// 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_HOOK_EVENT").is_some() || std::env::var_os("GROK_HOOK_NAME").is_some() +} + +#[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_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()); + unsafe { + std::env::remove_var("GROK_SESSION_ID"); + } + } + + #[test] + #[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"); + } + } +} + /// Pre-gate check: should hooks proceed? /// /// diff --git a/src/hooks/grok.rs b/src/hooks/grok.rs new file mode 100644 index 00000000..8ae74ae9 --- /dev/null +++ b/src/hooks/grok.rs @@ -0,0 +1,927 @@ +//! Grok Build (xAI `grok` CLI) native hook handlers and `$GROK_HOME`/`~/.grok` hooks. +//! +//! Observe-only events (SessionStart, UserPromptSubmit, PostToolUse) discard +//! stdout on Grok — never deliver bus messages there. Stop is a real gate: +//! deliver only via `hookSpecificOutput.additionalContext` on genuine end-of-turn. +//! + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::db::{HcomDb, InstanceRow}; +use crate::hooks::{DeliveryAck, HookPayload, common}; +use crate::instance_binding; +use crate::instance_lifecycle as lifecycle; +use crate::instances; +use crate::log; +use crate::paths; +use crate::shared::context::HcomContext; +use crate::shared::{ST_ACTIVE, ST_LISTENING}; + +const HCOM_TRIGGER: &str = ""; +const HOOK_TIMEOUT_SECS: u64 = 15; + +/// (Grok event name, hcom subcommand suffix) +const GROK_HOOK_COMMANDS: &[(&str, &str)] = &[ + ("SessionStart", "grok-sessionstart"), + ("UserPromptSubmit", "grok-userpromptsubmit"), + ("PreToolUse", "grok-pretooluse"), + ("PostToolUse", "grok-posttooluse"), + ("Stop", "grok-stop"), + ("SubagentStart", "grok-subagentstart"), + ("SubagentStop", "grok-subagentstop"), + ("SessionEnd", "grok-sessionend"), +]; + +#[derive(Debug, thiserror::Error)] +pub enum SetupError { + #[error("existing Grok config at {} could not be read: {source}", path.display())] + ExistingReadFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("existing Grok config at {} is not valid JSON: {source}", path.display())] + ExistingParseFailed { + path: PathBuf, + #[source] + source: serde_json::Error, + }, + #[error("existing Grok config at {} must be a JSON object", path.display())] + ExistingRootNotObject { path: PathBuf }, + #[error("failed to create Grok config directory {}: {source}", path.display())] + DirCreateFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("JSON serialization failed: {0}")] + SerializationFailed(#[from] serde_json::Error), + #[error("atomic write to {} failed: {source}", path.display())] + AtomicWriteFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("post-write Grok hook verification failed for {}", .0.display())] + PostWriteVerifyFailed(PathBuf), +} + +/// Resolve Grok config root: `$GROK_HOME` if set, else `/.grok`. +pub fn grok_config_dir() -> PathBuf { + if let Ok(home) = std::env::var("GROK_HOME") { + let trimmed = home.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + crate::runtime_env::tool_config_root().join(".grok") +} + +fn default_grok_config_dir() -> PathBuf { + if let Ok(home) = std::env::var("GROK_HOME") { + let trimmed = home.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } + dirs::home_dir().unwrap_or_default().join(".grok") +} + +pub fn get_grok_hooks_path() -> PathBuf { + grok_config_dir().join("hooks").join("hcom.json") +} + +fn build_grok_hook_command(command: &str) -> String { + let mut parts = crate::runtime_env::get_hcom_prefix(); + parts.push(command.to_string()); + parts.join(" ") +} + +fn is_hcom_grok_command(command: &str) -> bool { + let last = command.split_whitespace().last().unwrap_or(""); + GROK_HOOK_COMMANDS.iter().any(|(_, suffix)| last == *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 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!({}); + }; + 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!({}); + }; + let _ = db.rebind_instance_session(&instance_name, &session_id); + instance_binding::capture_and_store_launch_context(db, &instance_name); + update_position(db, ctx, payload, &instance_name); + lifecycle::set_status( + db, + &instance_name, + ST_LISTENING, + "start", + Default::default(), + ); + crate::runtime_env::set_terminal_title(&instance_name); + crate::relay::worker::ensure_worker(true); + common::notify_hook_instance_with_db(db, &instance_name); + // SessionStart is observe-only: 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( + db: &HcomDb, + ctx: &HcomContext, + payload: &HookPayload, +) -> (Value, Option) { + let Some(instance) = resolved_instance(db, ctx, payload) else { + return (json!({}), None); + }; + let prompt = payload + .raw + .get("prompt") + .and_then(Value::as_str) + .or_else(|| payload.raw.get("userPrompt").and_then(Value::as_str)) + .unwrap_or(""); + let context = if prompt.trim() == HCOM_TRIGGER + || prompt.trim().eq_ignore_ascii_case("hcom: wake") + || prompt.contains("[hcom") + || prompt.contains("hcom ") + { + "trigger" + } else { + "prompt" + }; + lifecycle::set_status(db, &instance.name, ST_ACTIVE, context, Default::default()); + // UserPromptSubmit is observe-only on Grok — stdout is discarded. Delivery + // is Stop(end_turn) → hookSpecificOutput.additionalContext only. + (json!({}), None) +} + +fn handle_pretooluse(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> Value { + if let Some(instance) = resolved_instance(db, ctx, payload) { + common::update_tool_status( + db, + &instance.name, + "grok", + &payload.tool_name, + &payload.tool_input, + ); + } + // PreToolUse is blocking on Grok; always allow. + json!({ "decision": "allow" }) +} + +fn handle_posttooluse( + db: &HcomDb, + ctx: &HcomContext, + payload: &HookPayload, +) -> (Value, Option) { + let Some(_instance) = resolved_instance(db, ctx, payload) else { + return (json!({}), None); + }; + // PostToolUse is observe-only on Grok — stdout is discarded. Keep pending + // until genuine Stop(end_turn). + (json!({}), None) +} + +/// 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 { + 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(); + reason.is_empty() + || reason == "end_turn" + || reason == "endturn" + || reason == "completed" + || reason == "stop" +} + +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); + }; + 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); + } + + // Stop is a real gate. Grok parses StopHookJson and feeds + // hookSpecificOutput.additionalContext back into the model. Do NOT use + // followup_message (not in the schema — silently dropped). + match common::prepare_pending_messages(db, &instance.name) { + Some(prepared) => { + log::log_info( + "hooks", + "grok.stop.additional_context", + &format!( + "instance={} bytes={}", + instance.name, + prepared.formatted.len() + ), + ); + ( + json!({ + "hookSpecificOutput": { + "additionalContext": prepared.formatted, + "additional_context": prepared.formatted, + } + }), + Some(prepared.ack), + ) + } + None => (json!({}), None), + } +} + +fn handle_sessionend(db: &HcomDb, ctx: &HcomContext, payload: &HookPayload) -> Value { + if let Some(instance) = resolved_instance(db, ctx, payload) { + let reason = payload + .raw + .get("reason") + .and_then(Value::as_str) + .unwrap_or("unknown"); + common::finalize_session(db, &instance.name, reason, None); + } + json!({}) +} + +/// Dispatch one Grok JSON-on-stdin hook. +pub fn dispatch_grok_hook(hook_name: &str) -> i32 { + let raw: Value = match serde_json::from_reader(std::io::stdin().lock()) { + Ok(value) => value, + Err(err) => { + log::log_warn( + "hooks", + "grok.parse_error", + &format!("hook={hook_name} err={err}"), + ); + return 0; + } + }; + let db = match HcomDb::open() { + Ok(db) => db, + Err(err) => { + log::log_warn( + "hooks", + "grok.db_error", + &format!("hook={hook_name} err={err}"), + ); + return 0; + } + }; + let ctx = HcomContext::from_os(); + if !common::hook_gate_check(&ctx, &db) { + return 0; + } + let payload = HookPayload::from_grok(hook_name, raw); + let (output, delivery_ack) = common::dispatch_with_panic_guard( + "grok", + hook_name, + (json!({ "decision": "allow" }), None), + || match hook_name { + "grok-sessionstart" => (handle_sessionstart(&db, &ctx, &payload), None), + "grok-userpromptsubmit" => handle_userpromptsubmit(&db, &ctx, &payload), + "grok-pretooluse" => (handle_pretooluse(&db, &ctx, &payload), None), + "grok-posttooluse" => handle_posttooluse(&db, &ctx, &payload), + "grok-stop" => handle_stop( + &db, + &ctx, + &payload, + 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, + std::env::var("HCOM_GROK_ACP").as_deref() == Ok("1"), + ), + "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(&"uvx hcom grok-stop")); + assert!(all_commands.contains(&"./custom-stop.sh")); + } + + #[test] + #[serial] + fn remove_preserves_unrelated_hooks() { + let (_dir, workspace, _guard) = grok_test_env(); + let hooks_path = workspace.join(".grok/hooks/hcom.json"); + std::fs::create_dir_all(hooks_path.parent().unwrap()).unwrap(); + std::fs::write( + &hooks_path, + serde_json::to_string_pretty(&json!({ + "hooks": { + "SessionEnd": [{ + "hooks": [{ "type": "command", "command": "./custom-end.sh" }] + }] + } + })) + .unwrap(), + ) + .unwrap(); + + try_setup_grok_hooks(false).unwrap(); + assert!(remove_grok_hooks()); + + let root: Value = + serde_json::from_str(&std::fs::read_to_string(hooks_path).unwrap()).unwrap(); + assert_eq!( + root["hooks"]["SessionEnd"], + json!([{ + "hooks": [{ "type": "command", "command": "./custom-end.sh" }] + }]) + ); + assert!( + root["hooks"] + .as_object() + .unwrap() + .get("SessionStart") + .is_none() + ); + } + + #[test] + fn payload_from_grok_reads_camel_case() { + let raw = json!({ + "sessionId": "sess-abc", + "hookEventName": "pre_tool_use", + "toolName": "run_terminal_command", + "toolInput": { "command": "ls" }, + "cwd": "/tmp/proj", + "workspaceRoot": "/tmp/proj" + }); + let payload = HookPayload::from_grok("grok-pretooluse", raw); + assert_eq!(payload.session_id.as_deref(), Some("sess-abc")); + assert_eq!(payload.tool, "grok"); + assert_eq!(payload.tool_name, "run_terminal_command"); + assert_eq!(payload.tool_input["command"], "ls"); + assert_eq!(payload.hook_name, "grok-pretooluse"); + } + + #[test] + fn payload_from_grok_reads_snake_case() { + let raw = json!({ + "session_id": "sess-xyz", + "tool_name": "search_replace", + "tool_input": { "file_path": "a.rs" } + }); + let payload = HookPayload::from_grok("grok-posttooluse", raw); + assert_eq!(payload.session_id.as_deref(), Some("sess-xyz")); + assert_eq!(payload.tool_name, "search_replace"); + } + + #[test] + fn end_turn_is_the_only_stop_delivery_reason() { + let payload = HookPayload::from_grok("grok-stop", json!({ "reason": "end_turn" })); + 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] + #[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() { + 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, false); + + 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/hooks/mod.rs b/src/hooks/mod.rs index b717db71..a71175c6 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; @@ -220,7 +221,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, @@ -439,6 +440,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 78b05711..f456877a 100644 --- a/src/integration_spec.rs +++ b/src/integration_spec.rs @@ -314,6 +314,17 @@ const COPILOT_HOOKS: &[&str] = &[ "copilot-sessionend", ]; +const GROK_HOOKS: &[&str] = &[ + "grok-sessionstart", + "grok-userpromptsubmit", + "grok-pretooluse", + "grok-posttooluse", + "grok-stop", + "grok-subagentstart", + "grok-subagentstop", + "grok-sessionend", +]; + // ── Help examples / extra-env tables ──────────────────────────────────── const CLAUDE_HELP_EXAMPLES: &[HelpEntry] = &[ @@ -394,6 +405,11 @@ 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"), +]; + const COPILOT_HELP_EXAMPLES: &[HelpEntry] = &[ ( "hcom copilot --model claude-haiku-4.5", @@ -1059,6 +1075,65 @@ 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, + // 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, + }, + // 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, + // 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, + // 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, + }, + launch: LaunchSpec { + args_env: Some("HCOM_GROK_ARGS"), + config_dir_env: Some("GROK_HOME"), + initial_prompt: InitialPromptShape::Positional, + uses_pty_default: true, + max_launch_count: 10, + background: BackgroundMode::HeadlessPty, + }, + resume: Some(ResumeSpec { + resume: ResumeArgs::Flag("--resume"), + fork: Some(ForkArgs::AppendFlag("--fork-session")), + }), + help: HelpSpec { + unique_examples: GROK_HELP_EXAMPLES, + extra_env: &[], + }, + status_detail: StatusDetailSpec { + bash: &["run_terminal_command", "Bash"], + file: &["search_replace", "write", "Edit", "Write", "MultiEdit"], + delegate: &["spawn_subagent", "Task"], + }, +}; + pub static ADHOC: IntegrationSpec = IntegrationSpec { tool: Tool::Adhoc, name: "adhoc", @@ -1123,6 +1198,7 @@ pub static ALL: &[&IntegrationSpec] = &[ &CURSOR, &KIMI, &COPILOT, + &GROK, &ADHOC, ]; @@ -1141,6 +1217,7 @@ impl Tool { Tool::Cursor => &CURSOR, Tool::Kimi => &KIMI, Tool::Copilot => &COPILOT, + Tool::Grok => &GROK, Tool::Adhoc => &ADHOC, } } @@ -1188,6 +1265,7 @@ mod tests { Tool::Cursor, Tool::Kimi, Tool::Copilot, + Tool::Grok, Tool::Pi, Tool::Omp, Tool::Adhoc, @@ -1255,8 +1333,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 1a404018..947db371 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, @@ -44,6 +45,7 @@ pub enum LaunchTool { Cursor, Kimi, Copilot, + Grok, Omp, } @@ -63,6 +65,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 +84,7 @@ impl LaunchTool { LaunchTool::Cursor => "cursor", LaunchTool::Kimi => "kimi", LaunchTool::Copilot => "copilot", + LaunchTool::Grok => "grok", } } @@ -101,6 +105,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 +175,8 @@ impl LaunchBackend { | LaunchTool::Antigravity | LaunchTool::Cursor | LaunchTool::Kimi - | LaunchTool::Copilot => LaunchBackend::HeadlessPty, + | LaunchTool::Copilot + | LaunchTool::Grok => LaunchBackend::HeadlessPty, } } } @@ -330,6 +336,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, @@ -448,6 +508,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)) @@ -770,6 +831,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(()) + } } } @@ -1690,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`. @@ -2362,6 +2447,50 @@ pub fn launch(db: &HcomDb, mut params: LaunchParams) -> Result { inside_ai_tool, ) } + 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)), + ("name_announced".to_string(), json!(true)), + ]), + ); + 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, + &grok_args, + ¶ms, + inside_ai_tool, + ) + } } })(); @@ -2460,6 +2589,14 @@ pub(crate) fn validate_tool_args(tool: &LaunchTool, args: &[String]) -> Vec crate::tools::copilot_preprocessing::validate_copilot_args(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 + } } } @@ -2571,6 +2708,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()); } @@ -2683,6 +2825,27 @@ 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() + ); + 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] fn validate_cursor_print_mode_fails_fast() { let errors = validate_tool_args(&LaunchTool::Cursor, &["--print".to_string()]); @@ -3119,6 +3282,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(); 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/screen.rs b/src/pty/screen.rs index 6b630204..58105807 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -606,6 +606,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/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 diff --git a/src/router.rs b/src/router.rs index 60080a5c..ee5e927e 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 5f3ae005..6cae370d 100644 --- a/src/shared/tool_detection.rs +++ b/src/shared/tool_detection.rs @@ -97,6 +97,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"), @@ -124,6 +134,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); @@ -176,6 +187,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, @@ -231,6 +247,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/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/grok.rs b/src/transcript/grok.rs new file mode 100644 index 00000000..778ed11a --- /dev/null +++ b/src/transcript/grok.rs @@ -0,0 +1,299 @@ +//! Grok Build transcript parser (`updates.jsonl`). +//! +//! Grok persists ACP-style session update lines under +//! `~/.grok/sessions///updates.jsonl`. +//! Each line is a JSON-RPC-ish envelope: +//! +//! ```jsonc +//! {"method":"session/update","params":{"update":{ +//! "sessionUpdate":"user_message_chunk", +//! "content":{"type":"text","text":"…"} +//! }}} +//! ``` +//! +//! We rebuild exchanges from user/agent message chunks and tool_call events. + +use std::path::Path; + +use serde_json::Value; + +use super::shared::{ + Exchange, ToolUse, finalize_action_text, normalize_tool_name, read_file_lossy, truncate_str, +}; + +fn update_kind(update: &Value) -> &str { + update + .get("sessionUpdate") + .or_else(|| update.get("session_update")) + .and_then(Value::as_str) + .unwrap_or("") +} + +fn content_text(content: &Value) -> String { + // Do not trim individual stream chunks — Grok's ACP concatenates raw text; + // trimming `"Hello "` + `"world"` would become `"Helloworld"`. + match content { + Value::String(s) => s.to_string(), + Value::Object(obj) => obj + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + Value::Array(blocks) => { + let mut parts = Vec::new(); + for block in blocks { + if let Some(t) = block.get("text").and_then(Value::as_str) + && !t.is_empty() + { + parts.push(t.to_string()); + } + } + parts.join("") + } + _ => String::new(), + } +} + +fn tool_from_call(update: &Value) -> Option { + let name = update + .get("title") + .or_else(|| update.get("toolName")) + .or_else(|| update.get("name")) + .and_then(Value::as_str) + .unwrap_or("tool"); + let input = update + .get("rawInput") + .or_else(|| update.get("input")) + .cloned() + .unwrap_or(Value::Object(Default::default())); + let file = input + .get("path") + .or_else(|| input.get("file_path")) + .or_else(|| input.get("target_file")) + .and_then(Value::as_str) + .map(|p| { + Path::new(p) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(p) + .to_string() + }); + let command = input + .get("command") + .and_then(Value::as_str) + .map(|s| truncate_str(s, 200).to_string()); + let is_error = update + .get("isError") + .or_else(|| update.get("is_error")) + .and_then(Value::as_bool) + .or_else(|| { + update + .get("status") + .and_then(Value::as_str) + .map(|s| matches!(s, "failed" | "error" | "cancelled" | "canceled")) + }) + .unwrap_or(false); + Some(ToolUse { + name: normalize_tool_name(name).to_string(), + is_error, + file, + command, + // Grok updates.jsonl rarely carries tool stdout on the same event; leave empty + // unless a future schema starts embedding it (other parsers use capture_tool_output). + output: None, + }) +} + +/// 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..3f473efe 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![crate::hooks::grok::grok_config_dir().join("sessions")] + } TranscriptDiscovery::OpenCodeDatabase | TranscriptDiscovery::KiloDatabase => Vec::new(), } } diff --git a/src/tui/db.rs b/src/tui/db.rs index b558becd..7caa368d 100644 --- a/src/tui/db.rs +++ b/src/tui/db.rs @@ -256,6 +256,7 @@ fn parse_tool(s: &str) -> Tool { Ok(crate::tool::Tool::Cursor) => Tool::Cursor, Ok(crate::tool::Tool::Kimi) => Tool::Kimi, Ok(crate::tool::Tool::Copilot) => Tool::Copilot, + Ok(crate::tool::Tool::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);