aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 20:06:52 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 20:07:18 +0300
commitf161be04613211d8fee7ef18e6e506df09da3ba1 (patch)
treeec65649c42f95104ec04c33d19c056f169a41189
parent45a4318bbbcb2c91d95faee6f31e5846ae3f9ec0 (diff)
fix: detect Grok Build agent state
refs #133
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/CONFIGURATION.md1
-rw-r--r--docs/next/INTEGRATIONS.md9
-rw-r--r--docs/next/README.md1
-rw-r--r--src/config/sound.rs3
-rw-r--r--src/detect.rs82
-rw-r--r--src/terminal/state.rs71
-rw-r--r--tests/cli_wrapper.rs8
-rw-r--r--website/index.html10
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