Skip to content

Commit 05a7f1a

Browse files
committed
feat: implement statusline hook handler to forward telemetry to daemon and downstream processes
1 parent 4f4be04 commit 05a7f1a

8 files changed

Lines changed: 389 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ghook/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gobby-hooks"
3-
version = "0.3.0"
3+
version = "0.3.1"
44
edition = "2024"
55
rust-version = "1.85"
66
authors = ["Josh Wilhelmi <hello@gobby.ai>"]

crates/ghook/src/main.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ mod cli_config;
2727
mod detach;
2828
mod diagnose;
2929
mod envelope;
30+
mod statusline;
3031
mod terminal_context;
3132
mod transport;
3233

@@ -130,10 +131,22 @@ fn run_gobby_owned(args: &Args) -> ExitCode {
130131
// registering phantom sessions. Short-circuit before any side effects: no
131132
// enqueue, no POST, no terminal-context enrichment.
132133
if hooks_disabled_by_env() {
134+
if statusline::is_statusline_hook(cli, hook_type) {
135+
return ExitCode::SUCCESS;
136+
}
133137
println!("{}", serde_json::json!({}));
134138
return ExitCode::SUCCESS;
135139
}
136140

141+
if statusline::is_statusline_hook(cli, hook_type) {
142+
let mut stdin_raw = Vec::with_capacity(4096);
143+
if let Err(e) = std::io::stdin().read_to_end(&mut stdin_raw) {
144+
eprintln!("ghook statusline: failed to read stdin: {e}");
145+
return ExitCode::SUCCESS;
146+
}
147+
return statusline::handle(&stdin_raw);
148+
}
149+
137150
let cfg = CliConfig::for_dispatch(cli);
138151
let is_critical = cfg.is_critical_hook(hook_type);
139152

crates/ghook/src/statusline.rs

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
//! Claude Code statusline handler.
2+
//!
3+
//! This is intentionally separate from the normal enqueue-first hook path.
4+
//! Claude reads statusline stdout directly on every tick, so the handler must
5+
//! preserve downstream stdout bytes exactly and must never expose transport
6+
//! failures to Claude as hook failures.
7+
8+
use serde_json::{Value, json};
9+
use std::ffi::OsStr;
10+
use std::io::{Read, Write};
11+
use std::process::{Command, ExitCode, Stdio};
12+
use std::sync::mpsc;
13+
use std::thread;
14+
use std::time::{Duration, Instant};
15+
16+
const STATUSLINE_ENDPOINT: &str = "/api/sessions/statusline";
17+
const DAEMON_POST_TIMEOUT: Duration = Duration::from_secs(2);
18+
const DAEMON_POST_JOIN_TIMEOUT: Duration = Duration::from_millis(300);
19+
const DOWNSTREAM_TIMEOUT: Duration = Duration::from_secs(5);
20+
21+
pub(crate) fn is_statusline_hook(cli: &str, hook_type: &str) -> bool {
22+
cli.eq_ignore_ascii_case("claude") && hook_type == "statusline"
23+
}
24+
25+
pub(crate) fn handle(stdin_raw: &[u8]) -> ExitCode {
26+
let downstream = std::env::var_os("GOBBY_STATUSLINE_DOWNSTREAM");
27+
let downstream = downstream.as_deref().filter(|command| !command.is_empty());
28+
let daemon_url = gobby_core::daemon_url::daemon_url();
29+
let mut stdout = std::io::stdout().lock();
30+
handle_with(stdin_raw, &daemon_url, downstream, &mut stdout)
31+
}
32+
33+
fn handle_with(
34+
stdin_raw: &[u8],
35+
daemon_url: &str,
36+
downstream: Option<&OsStr>,
37+
stdout: &mut impl Write,
38+
) -> ExitCode {
39+
let input = match serde_json::from_slice::<Value>(stdin_raw) {
40+
Ok(Value::Object(map)) => Value::Object(map),
41+
Ok(_) => {
42+
eprintln!("ghook statusline: expected JSON object");
43+
return ExitCode::SUCCESS;
44+
}
45+
Err(e) => {
46+
eprintln!("ghook statusline: invalid JSON: {e}");
47+
return ExitCode::SUCCESS;
48+
}
49+
};
50+
51+
if let Some(payload) = extract_payload(&input) {
52+
post_to_daemon_best_effort(payload, daemon_url);
53+
}
54+
55+
if let Some(command) = downstream
56+
&& let Some(bytes) = forward_downstream(command, stdin_raw)
57+
{
58+
let _ = stdout.write_all(&bytes);
59+
let _ = stdout.flush();
60+
}
61+
62+
ExitCode::SUCCESS
63+
}
64+
65+
fn extract_payload(input: &Value) -> Option<Value> {
66+
let session_id = input.get("session_id")?;
67+
if !python_truthy(session_id) {
68+
return None;
69+
}
70+
71+
let empty = serde_json::Map::new();
72+
let cost = input
73+
.get("cost")
74+
.and_then(Value::as_object)
75+
.unwrap_or(&empty);
76+
let model = input
77+
.get("model")
78+
.and_then(Value::as_object)
79+
.unwrap_or(&empty);
80+
let context_window = input
81+
.get("context_window")
82+
.and_then(Value::as_object)
83+
.unwrap_or(&empty);
84+
85+
Some(json!({
86+
"session_id": session_id.clone(),
87+
"model_id": model.get("id").cloned().unwrap_or_else(|| json!("")),
88+
"input_tokens": cost.get("input_tokens").cloned().unwrap_or_else(|| json!(0)),
89+
"output_tokens": cost.get("output_tokens").cloned().unwrap_or_else(|| json!(0)),
90+
"cache_creation_tokens": cost
91+
.get("cache_creation_tokens")
92+
.cloned()
93+
.unwrap_or_else(|| json!(0)),
94+
"cache_read_tokens": cost
95+
.get("cache_read_tokens")
96+
.cloned()
97+
.unwrap_or_else(|| json!(0)),
98+
"context_window_size": context_window.get("size").cloned().unwrap_or_else(|| json!(0)),
99+
}))
100+
}
101+
102+
fn python_truthy(value: &Value) -> bool {
103+
match value {
104+
Value::Null => false,
105+
Value::Bool(flag) => *flag,
106+
Value::Number(number) => {
107+
number.as_i64().is_some_and(|n| n != 0)
108+
|| number.as_u64().is_some_and(|n| n != 0)
109+
|| number.as_f64().is_some_and(|n| n != 0.0)
110+
}
111+
Value::String(text) => !text.is_empty(),
112+
Value::Array(items) => !items.is_empty(),
113+
Value::Object(map) => !map.is_empty(),
114+
}
115+
}
116+
117+
fn post_to_daemon_best_effort(payload: Value, daemon_url: &str) {
118+
let endpoint = format!("{daemon_url}{STATUSLINE_ENDPOINT}");
119+
let (tx, rx) = mpsc::channel();
120+
121+
thread::spawn(move || {
122+
let _ = ureq::post(&endpoint)
123+
.timeout(DAEMON_POST_TIMEOUT)
124+
.set("Content-Type", "application/json")
125+
.send_json(payload);
126+
let _ = tx.send(());
127+
});
128+
129+
let _ = rx.recv_timeout(DAEMON_POST_JOIN_TIMEOUT);
130+
}
131+
132+
fn forward_downstream(command: &OsStr, stdin_raw: &[u8]) -> Option<Vec<u8>> {
133+
let mut child = downstream_shell_command(command)
134+
.stdin(Stdio::piped())
135+
.stdout(Stdio::piped())
136+
.stderr(Stdio::null())
137+
.spawn()
138+
.ok()?;
139+
140+
if let Some(mut stdin) = child.stdin.take()
141+
&& stdin.write_all(stdin_raw).is_err()
142+
{
143+
let _ = child.kill();
144+
let _ = child.wait();
145+
return None;
146+
}
147+
148+
let started = Instant::now();
149+
loop {
150+
match child.try_wait() {
151+
Ok(Some(_status)) => {
152+
let mut stdout = Vec::new();
153+
let mut stdout_pipe = child.stdout.take()?;
154+
let _ = stdout_pipe.read_to_end(&mut stdout);
155+
return (!stdout.is_empty()).then_some(stdout);
156+
}
157+
Ok(None) if started.elapsed() < DOWNSTREAM_TIMEOUT => {
158+
thread::sleep(Duration::from_millis(10));
159+
}
160+
Ok(None) | Err(_) => {
161+
let _ = child.kill();
162+
let _ = child.wait();
163+
return None;
164+
}
165+
}
166+
}
167+
}
168+
169+
#[cfg(not(target_os = "windows"))]
170+
fn downstream_shell_command(command: &OsStr) -> Command {
171+
let mut shell = Command::new("sh");
172+
shell.arg("-c").arg(command);
173+
shell
174+
}
175+
176+
#[cfg(target_os = "windows")]
177+
fn downstream_shell_command(command: &OsStr) -> Command {
178+
let mut shell = Command::new("cmd");
179+
shell.arg("/C").arg(command);
180+
shell
181+
}
182+
183+
#[cfg(test)]
184+
mod tests {
185+
use super::*;
186+
use std::io::{Read, Write};
187+
use std::net::TcpListener;
188+
189+
const VALID_INPUT: &str = include_str!("../tests/fixtures/statusline/full-input.json");
190+
const VALID_PAYLOAD: &str = include_str!("../tests/fixtures/statusline/full-payload.json");
191+
const DEFAULT_INPUT: &str = include_str!("../tests/fixtures/statusline/defaults-input.json");
192+
const DEFAULT_PAYLOAD: &str =
193+
include_str!("../tests/fixtures/statusline/defaults-payload.json");
194+
195+
fn read_http_request(stream: &mut impl Read) -> String {
196+
let mut request = Vec::new();
197+
let mut chunk = [0_u8; 1024];
198+
let mut header_end = None;
199+
let mut content_length = None;
200+
201+
loop {
202+
let n = stream.read(&mut chunk).unwrap();
203+
assert!(n > 0, "client closed before request body was fully read");
204+
request.extend_from_slice(&chunk[..n]);
205+
206+
if header_end.is_none()
207+
&& let Some(pos) = request.windows(4).position(|window| window == b"\r\n\r\n")
208+
{
209+
let end = pos + 4;
210+
header_end = Some(end);
211+
let headers = String::from_utf8_lossy(&request[..end]);
212+
content_length = Some(
213+
headers
214+
.lines()
215+
.find_map(|line| {
216+
let (name, value) = line.split_once(':')?;
217+
name.eq_ignore_ascii_case("Content-Length")
218+
.then(|| value.trim().parse::<usize>().ok())
219+
.flatten()
220+
})
221+
.unwrap_or(0),
222+
);
223+
}
224+
225+
if let (Some(end), Some(len)) = (header_end, content_length)
226+
&& request.len() >= end + len
227+
{
228+
return String::from_utf8(request).unwrap();
229+
}
230+
}
231+
}
232+
233+
#[test]
234+
fn recognizes_only_claude_statusline_hook() {
235+
assert!(is_statusline_hook("claude", "statusline"));
236+
assert!(is_statusline_hook("CLAUDE", "statusline"));
237+
assert!(!is_statusline_hook("claude", "PreToolUse"));
238+
assert!(!is_statusline_hook("codex", "statusline"));
239+
}
240+
241+
#[test]
242+
fn extract_payload_matches_full_golden_fixture() {
243+
let input: Value = serde_json::from_str(VALID_INPUT).unwrap();
244+
let expected: Value = serde_json::from_str(VALID_PAYLOAD).unwrap();
245+
assert_eq!(extract_payload(&input), Some(expected));
246+
}
247+
248+
#[test]
249+
fn extract_payload_matches_default_golden_fixture() {
250+
let input: Value = serde_json::from_str(DEFAULT_INPUT).unwrap();
251+
let expected: Value = serde_json::from_str(DEFAULT_PAYLOAD).unwrap();
252+
assert_eq!(extract_payload(&input), Some(expected));
253+
}
254+
255+
#[test]
256+
fn extract_payload_returns_none_without_session_id() {
257+
assert!(extract_payload(&json!({"cost": {"input_tokens": 1}})).is_none());
258+
assert!(extract_payload(&json!({"session_id": ""})).is_none());
259+
assert!(extract_payload(&json!({"session_id": null})).is_none());
260+
assert!(extract_payload(&json!({"session_id": 0})).is_none());
261+
assert!(extract_payload(&json!({"session_id": false})).is_none());
262+
}
263+
264+
#[test]
265+
fn malformed_json_exits_success_without_stdout() {
266+
let mut stdout = Vec::new();
267+
let exit = handle_with(b"not json", "http://127.0.0.1:9", None, &mut stdout);
268+
assert_eq!(exit, ExitCode::SUCCESS);
269+
assert!(stdout.is_empty());
270+
}
271+
272+
#[test]
273+
fn posts_statusline_payload_to_daemon_endpoint() {
274+
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
275+
let addr = listener.local_addr().unwrap();
276+
let handle = thread::spawn(move || {
277+
let (mut stream, _) = listener.accept().unwrap();
278+
let request = read_http_request(&mut stream);
279+
assert!(request.contains("POST /api/sessions/statusline HTTP/1.1"));
280+
let expected: Value = serde_json::from_str(VALID_PAYLOAD).unwrap();
281+
let body = request.split("\r\n\r\n").nth(1).unwrap();
282+
let actual: Value = serde_json::from_str(body).unwrap();
283+
assert_eq!(actual, expected);
284+
stream
285+
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}")
286+
.unwrap();
287+
});
288+
289+
let mut stdout = Vec::new();
290+
let exit = handle_with(
291+
VALID_INPUT.as_bytes(),
292+
&format!("http://{addr}"),
293+
None,
294+
&mut stdout,
295+
);
296+
handle.join().unwrap();
297+
298+
assert_eq!(exit, ExitCode::SUCCESS);
299+
assert!(stdout.is_empty());
300+
}
301+
302+
#[test]
303+
fn downstream_stdout_passthrough_preserves_bytes() {
304+
let mut stdout = Vec::new();
305+
let exit = handle_with(
306+
br#"{"session_id":"sess-123"}"#,
307+
"http://127.0.0.1:9",
308+
Some(OsStr::new("printf 'status ok'")),
309+
&mut stdout,
310+
);
311+
312+
assert_eq!(exit, ExitCode::SUCCESS);
313+
assert_eq!(stdout, b"status ok");
314+
}
315+
316+
#[test]
317+
fn downstream_timeout_returns_before_six_seconds() {
318+
if cfg!(target_os = "windows") {
319+
return;
320+
}
321+
322+
let started = Instant::now();
323+
let mut stdout = Vec::new();
324+
let exit = handle_with(
325+
br#"{"session_id":"sess-123"}"#,
326+
"http://127.0.0.1:9",
327+
Some(OsStr::new("sleep 10")),
328+
&mut stdout,
329+
);
330+
331+
assert_eq!(exit, ExitCode::SUCCESS);
332+
assert!(stdout.is_empty());
333+
assert!(
334+
started.elapsed() < Duration::from_secs(6),
335+
"downstream timeout should fire before CI hangs"
336+
);
337+
}
338+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"session_id": "sess-123"
3+
}

0 commit comments

Comments
 (0)