1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
// Grok CLI adapter: builds `grok` commands and parses buffered JSON output.
// Probes the `grok` binary specifically — not the generic `agent` name.
use anyhow::{bail, Result};
use chrono::Local;
use serde_json::{json, Value};
use std::path::Path;
use std::process::Command;
use super::RunOpts;
use crate::rate_limit;
use crate::types::*;
pub struct GrokAgent;
impl super::Agent for GrokAgent {
fn kind(&self) -> AgentKind {
AgentKind::Grok
}
fn streaming(&self) -> bool {
false
}
fn accepts_interactive_input(&self) -> bool {
false
}
fn build_command(&self, prompt: &str, opts: &RunOpts) -> Result<Command> {
let prompt_with_ctx = super::embed_context_in_prompt(prompt, &opts.context_files)?;
let allow_result = super::read_only::allow_result_file_write(opts);
let effective_prompt = if allow_result {
super::read_only::read_only_prompt(&prompt_with_ctx, opts)
} else {
prompt_with_ctx
};
let mut cmd = Command::new("grok");
cmd.args(["-p", &effective_prompt, "--output-format", "json"]);
// Global Claude Code Stop hooks (`hiboss hook stop`) refuse the first
// exit until `hiboss ask` returns — and `hiboss ask` blocks for a human.
// Headless `-p` has no human, so the session writes zero bytes and the
// watchdog reaps it (t-764b2a1d, 1200s). Measured: `--deny Bash(hiboss:*)`
// lets grok refuse the gate by policy and exit normally. Adapter-local
// because only this CLI speaks `--deny`; scoped to hiboss (not all Bash)
// so normal tool use stays intact. The `:*` covers ask/hook/notify.
cmd.args(["--deny", "Bash(hiboss:*)"]);
if opts.read_only && !allow_result {
cmd.args(["--permission-mode", "plan"]);
} else {
// Without this grok asks for approval it can never receive: in
// headless `-p` mode it renders no prompt, it just abandons the tool
// call, returns `stopReason: "cancelled"` and exits 0. Measured in a
// scratch repo — "add a line to a.txt" left the file untouched and
// still billed, and a real 32-turn run burned $1.04 before being
// cancelled the same way. `--permission-mode auto` is NOT enough; it
// cancels identically. Every other adapter passes its own form of
// this (codex `--full-auto`, agy/claude `--dangerously-skip-
// permissions`, droid `--skip-permissions-unsafe`, kilo `--auto`).
// Result-file audits need the same write path with prompt-level RO.
cmd.arg("--always-approve");
}
if let Some(ref model) = opts.model {
cmd.args(["--model", model]);
}
// grok's --debug-file grows throughout a run, giving both reapers a byte
// signal for proof of life. Without it, grok is silent on the PTY until
// exit and looks identical to a dead process to the first-token detector.
// Mirrors agy's --log-file pattern; the caller decides whether the path
// is watchable via env_with_agent_log.
if let Some(log_file) = super::agent_log_from_opts(opts) {
if let Some(parent) = std::path::Path::new(log_file).parent() {
let _ = std::fs::create_dir_all(parent);
}
cmd.args(["--debug-file", log_file]);
}
if let Some(ref session_id) = opts.session_id {
cmd.args(["-r", session_id]);
}
if let Some(ref dir) = opts.dir {
let path = Path::new(dir);
if !path.is_dir() {
bail!("Workspace path does not exist: {dir}");
}
cmd.args(["--cwd", dir]);
cmd.current_dir(dir);
}
Ok(cmd)
}
fn parse_event(&self, _task_id: &TaskId, _line: &str) -> Option<TaskEvent> {
None
}
fn parse_completion(&self, output: &str) -> CompletionInfo {
parse_grok_completion(output)
}
fn served_models(&self) -> Result<Option<Vec<String>>> {
let mut cmd = Command::new("grok");
cmd.arg("models");
let Some(output) = super::model_validation::run_cmd_with_timeout(cmd, std::time::Duration::from_secs(2)) else {
return Ok(None);
};
let models = parse_grok_models_output(&output);
Ok(if models.is_empty() { None } else { Some(models) })
}
}
fn parse_grok_models_output(output: &str) -> Vec<String> {
let mut models = Vec::new();
let mut in_models_section = false;
for line in output.lines() {
let trimmed = line.trim();
if trimmed.contains("Available models:") {
in_models_section = true;
continue;
}
if !in_models_section { continue; }
let Some(clean) = trimmed.strip_prefix('*').or_else(|| trimmed.strip_prefix('-')) else { continue; };
let clean = clean.trim();
let model_name = clean.split_whitespace().next().unwrap_or(clean);
if !model_name.is_empty() && !models.contains(&model_name.to_string()) {
models.push(model_name.to_string());
}
}
models
}
#[cfg(test)]
mod parser_tests {
use super::parse_grok_models_output;
#[test]
fn parses_current_grok_models_output_and_ignores_footer_text() {
let output = r#"You are logged in with grok.com.
Default model: grok-4.6
Available models:
* grok-4.6 (default)
- grok-4.5
"#;
assert_eq!(parse_grok_models_output(output), ["grok-4.6", "grok-4.5"]);
assert_eq!(parse_grok_models_output(&format!("{output}Footer text")), ["grok-4.6", "grok-4.5"]);
}
}
/// Find grok's JSON envelope inside a buffer that also carries aid's own writes.
///
/// `finalize_buffered` appends the terminal sentinel to `full_output` *before*
/// calling `parse_completion`, so the buffer is never bare JSON — every grok run
/// failed a whole-buffer parse, not just the nudged ones. An echoed auto-nudge
/// adds a second contaminant, ahead of the envelope instead of after it.
///
/// So candidates are the start of the buffer plus every line beginning with `{`,
/// each parsed with a streaming deserializer that stops at the end of one value
/// and ignores whatever follows. The last value that parses wins; aid's own
/// wording is never matched on.
fn extract_envelope(output: &str) -> Option<Value> {
let trimmed = output.trim();
let mut found = first_value(trimmed);
for (idx, _) in trimmed.match_indices('\n') {
let rest = trimmed[idx + 1..].trim_start();
if rest.starts_with('{')
&& let Some(value) = first_value(rest)
{
found = Some(value);
}
}
found
}
fn first_value(input: &str) -> Option<Value> {
serde_json::Deserializer::from_str(input)
.into_iter::<Value>()
.next()
.and_then(Result::ok)
.filter(Value::is_object)
}
pub fn extract_response(output: &str) -> Option<String> {
let value = extract_envelope(output)?;
value
.get("text")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
/// The buffer handed here is not grok's stdout alone. aid writes into the same
/// PTY, and the terminal echoes it back: an auto-nudge lands as a bare line
/// *before* grok's envelope, and the terminal sentinel lands after it. Requiring
/// the whole buffer to be one JSON document therefore failed every run that was
/// idle long enough to be nudged — measured on t-cd0bb8dd (8m16s, `end_turn`,
/// real work committed) and t-137fe385, both stored Failed, while a 27s run that
/// was never nudged parsed fine. The stopReason check below never ran in exactly
/// the cases it was written for.
pub fn parse_grok_completion(output: &str) -> CompletionInfo {
let Some(value) = extract_envelope(output) else {
return failed_completion();
};
if value.get("type").and_then(Value::as_str) == Some("error") {
if let Some(message) = value.get("message").and_then(Value::as_str) {
maybe_mark_rate_limit(message);
}
return failed_completion();
}
// grok reports a cut-short run in the same envelope shape as a good one: no
// `type: "error"`, a populated `text`, real usage and real cost. Only
// `stopReason` tells them apart, and without this check every truncated run
// recorded as Done — `t-560628e5` and `t-2a1b09aa` are still stored that
// way, and `t-c7ae82a8` stopped mid-sentence inside its own Findings
// section after 5 turns and $0.22, having spent real money on a report
// nobody could use.
//
// Keyed to values actually captured rather than to a guessed enum:
// `end_turn` on completed runs, `cancelled` on truncated ones. An
// unrecognised value is left alone instead of failed — inventing the shape
// of output we have not seen is what got the previous round of completion
// detectors blocked.
if value.get("stopReason").and_then(Value::as_str) == Some("cancelled") {
return failed_completion();
}
let tokens = value
.pointer("/usage/total_tokens")
.and_then(Value::as_i64)
.filter(|total| *total > 0);
let model = extract_model_usage_key(&value);
let cost_usd = value
.get("total_cost_usd")
.and_then(Value::as_f64)
.or_else(|| model_usage_cost(&value));
CompletionInfo {
tokens,
status: TaskStatus::Done,
model,
cost_usd,
exit_code: None,
}
}
fn failed_completion() -> CompletionInfo {
CompletionInfo {
tokens: None,
status: TaskStatus::Failed,
model: None,
cost_usd: None,
exit_code: None,
}
}
fn extract_model_usage_key(value: &Value) -> Option<String> {
value
.get("modelUsage")
.and_then(Value::as_object)
.and_then(|usage| usage.keys().next())
.cloned()
}
fn model_usage_cost(value: &Value) -> Option<f64> {
let usage = value.get("modelUsage")?.as_object()?;
usage.values().find_map(|entry| entry.get("costUSD").and_then(Value::as_f64))
}
fn maybe_mark_rate_limit(detail: &str) {
if rate_limit::is_rate_limit_error_for_agent(detail, &AgentKind::Grok) {
rate_limit::mark_rate_limited(&AgentKind::Grok, None, detail);
}
}
pub fn make_completion_event(task_id: &TaskId, info: &CompletionInfo) -> TaskEvent {
let detail = match info.tokens {
Some(tokens) => format!("completed with {tokens} tokens"),
None => "completed".to_string(),
};
let mut metadata = json!({});
let mut has_fields = false;
if let Some(tokens) = info.tokens {
metadata["tokens"] = json!(tokens);
has_fields = true;
}
if let Some(model) = info.model.as_deref() {
metadata["model"] = json!(model);
has_fields = true;
}
if let Some(cost_usd) = info.cost_usd {
metadata["cost_usd"] = json!(cost_usd);
has_fields = true;
}
TaskEvent {
task_id: task_id.clone(),
timestamp: Local::now(),
event_kind: EventKind::Completion,
detail,
metadata: has_fields.then_some(metadata),
}
}
#[cfg(test)]
#[path = "grok_tests.rs"]
mod tests;