diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-17 20:06:52 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-17 20:07:18 +0300 |
| commit | f161be04613211d8fee7ef18e6e506df09da3ba1 (patch) | |
| tree | ec65649c42f95104ec04c33d19c056f169a41189 | |
| parent | 45a4318bbbcb2c91d95faee6f31e5846ae3f9ec0 (diff) | |
fix: detect Grok Build agent state
refs #133
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | docs/next/CONFIGURATION.md | 1 | ||||
| -rw-r--r-- | docs/next/INTEGRATIONS.md | 9 | ||||
| -rw-r--r-- | docs/next/README.md | 1 | ||||
| -rw-r--r-- | src/config/sound.rs | 3 | ||||
| -rw-r--r-- | src/detect.rs | 82 | ||||
| -rw-r--r-- | src/terminal/state.rs | 71 | ||||
| -rw-r--r-- | tests/cli_wrapper.rs | 8 | ||||
| -rw-r--r-- | website/index.html | 10 |
9 files changed, 176 insertions, 10 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 93caba87..32148d7b 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -10,6 +10,7 @@ - Renamed the focused pane fullscreen keybinding to `keys.zoom`; `keys.fullscreen` remains supported as a legacy alias. ### Fixed +- Grok Build is now detected as `grok`, with basic working, blocked, and idle state detection. Conflicting known-agent hook labels are ignored once native foreground-process detection identifies a different known agent. (#133) - GitHub Copilot is now correctly detected when its process name is `copilot`. - Integration installs now respect `PI_CODING_AGENT_DIR`, `CLAUDE_CONFIG_DIR`, and `CODEX_HOME` when choosing Pi, Claude Code, and Codex config paths. - Split pane resize hit areas no longer overlap the first content column or row, making text selection work from the start of right and bottom panes. diff --git a/docs/next/CONFIGURATION.md b/docs/next/CONFIGURATION.md index 59956c0d..e988e72e 100644 --- a/docs/next/CONFIGURATION.md +++ b/docs/next/CONFIGURATION.md @@ -402,6 +402,7 @@ available agent keys: - `kimi` - `droid` - `amp` +- `grok` ## experimental diff --git a/docs/next/INTEGRATIONS.md b/docs/next/INTEGRATIONS.md index ff46d0c5..23b41ab8 100644 --- a/docs/next/INTEGRATIONS.md +++ b/docs/next/INTEGRATIONS.md @@ -108,6 +108,7 @@ notes: - claude code hooks also run inside subagents. herdr treats subagent `working` and `blocked` reports as real pane state. - subagent stop/release events are converted to `working` by the bundled hook script so a completed subagent does not make the parent claude pane look idle. - `PostToolUse` and `PostToolUseFailure` move the pane back to `working` after a permissioned tool call resolves. +- some non-claude tools, including grok cli, may load claude settings or plugins. herdr ignores conflicting known-agent hook labels once native foreground-process detection identifies a different known agent. uninstall: @@ -209,6 +210,14 @@ this removes: ~/.config/opencode/plugins/herdr-agent-state.js ``` +## grok cli + +herdr does not currently install a grok hook or plugin. + +grok is heuristic-only in herdr. herdr detects `grok` and `grok-build` from the foreground process and uses screen heuristics for `working`, `blocked`, and `idle` states. + +grok cli may load claude settings or plugins as a compatibility feature, including hooks from `~/.claude/settings.json`. if that causes the claude herdr hook to run in a grok pane, herdr ignores the conflicting `claude` hook label once native foreground-process detection identifies the pane as `grok`. + ## amp herdr does not currently install an Amp plugin. diff --git a/docs/next/README.md b/docs/next/README.md index f4587261..16649d6e 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -177,6 +177,7 @@ automatic detection works out of the box. process name matching plus terminal ou | [droid](https://factory.ai) | ✓ | ✓ | ✓ | | [amp](https://ampcode.com) | ✓ | ✓ | ✓ | | [opencode](https://github.com/anomalyco/opencode) | ✓ | ✓ | ✓ | +| [grok cli](https://x.ai/grok) | ✓ | ✓ | ✓ | detected but not fully tested: gemini cli, cursor agent, cline, kimi, github copilot cli. diff --git a/src/config/sound.rs b/src/config/sound.rs index 6219d698..d4e279c3 100644 --- a/src/config/sound.rs +++ b/src/config/sound.rs @@ -36,6 +36,7 @@ pub struct AgentSoundOverrides { pub kimi: AgentSoundSetting, pub droid: AgentSoundSetting, pub amp: AgentSoundSetting, + pub grok: AgentSoundSetting, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] @@ -122,6 +123,7 @@ impl AgentSoundOverrides { Some(Agent::Kimi) => self.kimi, Some(Agent::Droid) => self.droid, Some(Agent::Amp) => self.amp, + Some(Agent::Grok) => self.grok, None => AgentSoundSetting::Default, } } @@ -153,6 +155,7 @@ impl Default for AgentSoundOverrides { kimi: AgentSoundSetting::Default, droid: AgentSoundSetting::Off, amp: AgentSoundSetting::Default, + grok: AgentSoundSetting::Default, } } } diff --git a/src/detect.rs b/src/detect.rs index 9c98a1b9..5d890b90 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -30,6 +30,7 @@ pub enum Agent { Kimi, Droid, Amp, + Grok, } pub fn agent_label(agent: Agent) -> &'static str { @@ -45,6 +46,7 @@ pub fn agent_label(agent: Agent) -> &'static str { Agent::Kimi => "kimi", Agent::Droid => "droid", Agent::Amp => "amp", + Agent::Grok => "grok", } } @@ -62,6 +64,7 @@ pub fn parse_agent_label(agent: &str) -> Option<Agent> { "kimi" => Some(Agent::Kimi), "droid" => Some(Agent::Droid), "amp" | "amp-local" => Some(Agent::Amp), + "grok" | "grok-build" => Some(Agent::Grok), _ => None, } } @@ -83,6 +86,7 @@ pub fn identify_agent(process_name: &str) -> Option<Agent> { "kimi" => Some(Agent::Kimi), "droid" => Some(Agent::Droid), "amp" | "amp-local" => Some(Agent::Amp), + "grok" | "grok-build" => Some(Agent::Grok), _ => None, } } @@ -124,6 +128,7 @@ pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState { Agent::Kimi => detect_kimi(screen_content), Agent::Droid => detect_droid(screen_content), Agent::Amp => detect_amp(screen_content), + Agent::Grok => detect_grok(screen_content), } } @@ -421,6 +426,41 @@ fn detect_amp(content: &str) -> AgentState { AgentState::Idle } +/// Grok Build detection. +/// +/// Blocked permission prompts display a whitelist scope selector with choices +/// like "Yes, proceed" and "No, reject". Working turns show a braille spinner +/// status line such as "⠋ Waiting… 1.8s" plus live controls like +/// "Ctrl+c:cancel" and "Ctrl+Enter:interject". +fn detect_grok(content: &str) -> AgentState { + let lower = content.to_lowercase(); + + if lower.contains("use ← → to choose permission whitelist scope") + || lower.contains("yes, proceed") + || lower.contains("no, reject") + || lower.contains("ctrl+o:yolo") + || lower.contains(":scope") + { + return AgentState::Blocked; + } + + if has_braille_spinner(content) + && (lower.contains("waiting") + || lower.contains("run ") + || lower.contains("read ") + || lower.contains("search ") + || lower.contains("list ")) + { + return AgentState::Working; + } + + if lower.contains("ctrl+c:cancel") && lower.contains("ctrl+enter:interject") { + return AgentState::Working; + } + + AgentState::Idle +} + /// Check for braille spinner characters at the start of a line. /// These are the Unicode braille pattern dots used by CLI spinners. fn has_braille_spinner(content: &str) -> bool { @@ -689,6 +729,8 @@ mod tests { assert_eq!(identify_agent("kimi"), Some(Agent::Kimi)); assert_eq!(identify_agent("copilot"), Some(Agent::GithubCopilot)); assert_eq!(identify_agent("ghcs"), Some(Agent::GithubCopilot)); + assert_eq!(identify_agent("grok"), Some(Agent::Grok)); + assert_eq!(identify_agent("grok-build"), Some(Agent::Grok)); } #[test] @@ -701,6 +743,7 @@ mod tests { Some(Agent::GithubCopilot) ); assert_eq!(parse_agent_label("amp-local"), Some(Agent::Amp)); + assert_eq!(parse_agent_label("grok-build"), Some(Agent::Grok)); } #[test] @@ -708,6 +751,7 @@ mod tests { assert_eq!(agent_label(Agent::Pi), "pi"); assert_eq!(agent_label(Agent::GithubCopilot), "copilot"); assert_eq!(agent_label(Agent::OpenCode), "opencode"); + assert_eq!(agent_label(Agent::Grok), "grok"); } #[test] @@ -1323,6 +1367,44 @@ mod tests { assert_eq!(identify_agent("amp-local"), Some(Agent::Amp)); } + // ---- Grok ---- + + #[test] + fn grok_blocked_on_permission_prompt() { + let screen = "Show recent commit history for analysis\n\ + git -C /home/can/Projects/herdr log --oneline --decorate -n 12\n\ + Use ← → to choose permission whitelist scope\n\n\ + 1 (○) Always allow: git -C\n\ + 2 (●) Yes, proceed\n\ + 3 (○) No, reject (type to add feedback)\n\n\ + 1/3:select │ ←/→:scope │ Ctrl+o:yolo │ Ctrl+c:cancel"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); + } + + #[test] + fn grok_blocked_wins_over_spinner() { + let screen = "⠹ Run git 30s\nYes, proceed\nNo, reject (type to add feedback)"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Blocked); + } + + #[test] + fn grok_working_on_waiting_spinner() { + let screen = "⠋ Waiting… 1.8s\nCtrl+c:cancel │ Ctrl+Enter:interject"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); + } + + #[test] + fn grok_working_on_tool_spinner() { + let screen = "⠼ Run git -C /home/can/Projects/herdr log --oneline 1.0s"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Working); + } + + #[test] + fn grok_idle_after_turn_completed() { + let screen = "yo\n\nTurn completed in 1.7s.\n\n╭────╮\n│ ❯ │\n╰─ gpt-5.4 ─╯"; + assert_eq!(detect_state(Some(Agent::Grok), screen), AgentState::Idle); + } + // ---- Helpers ---- #[test] diff --git a/src/terminal/state.rs b/src/terminal/state.rs index f9d5b522..8c27f1a5 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -78,11 +78,13 @@ impl TerminalState { let previous_detected_agent = self.detected_agent; self.detected_agent = agent; self.fallback_state = fallback_state; - if previous_detected_agent.is_some() - && agent != previous_detected_agent - && self.hook_authority.as_ref().is_some_and(|authority| { - crate::detect::parse_agent_label(&authority.agent_label) == previous_detected_agent - }) + if self.hook_authority_conflicts_with_detected_agent(agent) + || (previous_detected_agent.is_some() + && agent != previous_detected_agent + && self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label) + == previous_detected_agent + })) { self.hook_authority = None; } @@ -117,6 +119,9 @@ impl TerminalState { let previous_agent_label = self.effective_agent_label().map(str::to_string); let previous_known_agent = self.effective_known_agent(); let previous_state = self.state; + if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { + return None; + } self.hook_authority = Some(HookAuthority { source, agent_label, @@ -127,6 +132,24 @@ impl TerminalState { self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } + fn hook_authority_conflicts_with_detected_agent(&self, detected_agent: Option<Agent>) -> bool { + let Some(detected_agent) = detected_agent else { + return false; + }; + self.hook_authority.as_ref().is_some_and(|authority| { + crate::detect::parse_agent_label(&authority.agent_label) + .is_some_and(|hook_agent| hook_agent != detected_agent) + }) + } + + fn known_agent_label_conflicts_with_detected_agent(&self, agent_label: &str) -> bool { + let Some(detected_agent) = self.detected_agent else { + return false; + }; + crate::detect::parse_agent_label(agent_label) + .is_some_and(|hook_agent| hook_agent != detected_agent) + } + fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool { let Some(seq) = seq else { return !self.hook_report_sequences.contains_key(source); @@ -418,6 +441,44 @@ mod tests { } #[test] + fn known_hook_authority_does_not_override_different_detected_agent() { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(Agent::Grok), AgentState::Working); + let change = terminal.set_hook_authority( + "herdr:claude".into(), + "claude".into(), + AgentState::Blocked, + None, + None, + ); + + assert!(change.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, Some(Agent::Grok)); + assert_eq!(terminal.effective_agent_label(), Some("grok")); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] + fn detected_agent_clears_conflicting_known_hook_authority() { + let mut terminal = test_terminal(); + terminal.set_hook_authority( + "herdr:claude".into(), + "claude".into(), + AgentState::Blocked, + None, + None, + ); + + terminal.set_detected_state(Some(Agent::Grok), AgentState::Working); + + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.detected_agent, Some(Agent::Grok)); + assert_eq!(terminal.effective_agent_label(), Some("grok")); + assert_eq!(terminal.state, AgentState::Working); + } + + #[test] fn border_label_prefers_manual_label_over_agent_label() { let mut terminal = test_terminal(); terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle); diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index dd07f760..d43f52ef 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -399,11 +399,9 @@ fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> let mut line = String::new(); let mut reader = BufReader::new(stream.try_clone().unwrap()); reader.read_line(&mut line).unwrap(); - stream - .write_all(br#"{"id":"test","result":{"type":"ok"}}"#) - .unwrap(); - stream.write_all(b"\n").unwrap(); - stream.flush().unwrap(); + let _ = stream.write_all(br#"{"id":"test","result":{"type":"ok"}}"#); + let _ = stream.write_all(b"\n"); + let _ = stream.flush(); return Some(line); } Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { diff --git a/website/index.html b/website/index.html index e0f0fd63..509412c3 100644 --- a/website/index.html +++ b/website/index.html @@ -530,6 +530,16 @@ </tr> <tr> <td> + <a href="https://x.ai/grok" + >grok cli</a + > + </td> + <td><span class="check">✓</span></td> + <td><span class="check">✓</span></td> + <td><span class="check">✓</span></td> + </tr> + <tr> + <td> <a href="https://github.com/google-gemini/gemini-cli" >gemini cli</a |
