Skip to main content

cove_cli/commands/
hook.rs

1// ── Claude Code hook handler ──
2//
3// Called by Claude Code hooks to write Cove state events.
4// Reads JSON from stdin, determines state, appends to ~/.cove/events/{session_id}.jsonl.
5//
6// Hook → state mapping:
7//   UserPromptSubmit                       → working
8//   PreToolUse (asking tools)              → asking
9//   PreToolUse (other tools)               → waiting
10//   PostToolUse                            → working
11//   Stop                                   → idle
12
13use std::fs;
14use std::io::{self, Read};
15use std::path::Path;
16
17use serde::Deserialize;
18
19use crate::cli::HookEvent;
20use crate::events;
21use crate::naming;
22use crate::tmux;
23
24// ── Types ──
25
26#[derive(Deserialize)]
27struct HookInput {
28    session_id: String,
29    cwd: String,
30    /// Tool name from PreToolUse/PostToolUse payloads (e.g. "Bash", "AskUserQuestion").
31    #[serde(default)]
32    tool_name: String,
33}
34
35/// Check if the session's event file contains at least one "working" entry,
36/// proving the user has submitted a prompt in this session.
37fn has_working_event(session_id: &str) -> bool {
38    has_working_event_in(session_id, &events::events_dir())
39}
40
41fn has_working_event_in(session_id: &str, dir: &Path) -> bool {
42    let path = dir.join(format!("{session_id}.jsonl"));
43    fs::read_to_string(path)
44        .map(|content| {
45            content
46                .lines()
47                .any(|line| line.contains(r#""state":"working""#))
48        })
49        .unwrap_or(false)
50}
51
52// ── Constants ──
53
54/// Tools that represent Claude asking the user a question (not a permission prompt).
55pub const ASKING_TOOLS: &[&str] = &["AskUserQuestion", "ExitPlanMode", "EnterPlanMode"];
56
57// ── Public API ──
58
59/// Map a hook event + tool name to the state string that gets written to the event file.
60pub fn determine_state(event: &HookEvent, tool_name: &str) -> &'static str {
61    match event {
62        HookEvent::UserPrompt | HookEvent::AskDone | HookEvent::PostTool => "working",
63        HookEvent::Stop => "idle",
64        HookEvent::SessionEnd => "end",
65        HookEvent::Ask => "asking",
66        HookEvent::PreTool => {
67            if ASKING_TOOLS.contains(&tool_name) {
68                "asking"
69            } else {
70                "waiting"
71            }
72        }
73    }
74}
75
76pub fn run(event: HookEvent) -> Result<(), String> {
77    let mut input = String::new();
78    io::stdin()
79        .read_to_string(&mut input)
80        .map_err(|e| format!("read stdin: {e}"))?;
81
82    let hook: HookInput =
83        serde_json::from_str(&input).map_err(|e| format!("parse hook input: {e}"))?;
84
85    let state = determine_state(&event, &hook.tool_name);
86
87    // Suppress the initial "idle" on session startup — only write it after
88    // the user has submitted at least one prompt (i.e. a "working" event exists).
89    if state == "idle" && !has_working_event(&hook.session_id) {
90        return Ok(());
91    }
92
93    // $TMUX_PANE uniquely identifies which tmux pane Claude is running in.
94    // This lets the sidebar distinguish sessions even when they share a cwd.
95    let pane_id = std::env::var("TMUX_PANE").unwrap_or_default();
96
97    events::write_event(&hook.session_id, &hook.cwd, &pane_id, state)?;
98
99    // Auto-update window name if the branch changed (e.g. Claude switched branches
100    // or created a worktree). Silently ignore errors — hooks must never block Claude.
101    let _ = maybe_rename_window(&hook.cwd, &pane_id);
102
103    Ok(())
104}
105
106/// Recompute the window name from the stored base + current git branch.
107/// Renames the window if the name has drifted.
108fn maybe_rename_window(cwd: &str, pane_id: &str) -> Result<(), String> {
109    let base = tmux::get_window_option(pane_id, "@cove_base")?;
110    let expected = naming::build_window_name(&base, cwd);
111    let current = tmux::get_window_name(pane_id)?;
112
113    if current != expected {
114        tmux::rename_window(pane_id, &expected)?;
115    }
116    Ok(())
117}
118
119// ── Tests ──
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use std::fs::OpenOptions;
125    use std::io::Write;
126
127    #[test]
128    fn test_write_event_creates_file() {
129        let dir = tempfile::tempdir().unwrap();
130        let events = dir.path().join("events");
131
132        fs::create_dir_all(&events).unwrap();
133        let path = events.join("test-session.jsonl");
134
135        let mut file = OpenOptions::new()
136            .create(true)
137            .append(true)
138            .open(&path)
139            .unwrap();
140        writeln!(file, r#"{{"state":"working","cwd":"/tmp","ts":1234}}"#).unwrap();
141
142        let content = fs::read_to_string(&path).unwrap();
143        assert!(content.contains(r#""state":"working""#));
144        assert!(content.contains(r#""cwd":"/tmp""#));
145    }
146
147    #[test]
148    fn test_has_working_event_empty() {
149        let dir = tempfile::tempdir().unwrap();
150        assert!(!has_working_event_in("no-such-session", dir.path()));
151    }
152
153    #[test]
154    fn test_has_working_event_with_working() {
155        let dir = tempfile::tempdir().unwrap();
156        let path = dir.path().join("test-session.jsonl");
157
158        fs::write(
159            &path,
160            r#"{"state":"working","cwd":"/tmp","pane_id":"%1","ts":1000}
161{"state":"idle","cwd":"/tmp","pane_id":"%1","ts":1001}
162"#,
163        )
164        .unwrap();
165
166        assert!(has_working_event_in("test-session", dir.path()));
167    }
168}