// Unified Claude Code hook. // // Single binary handling all hook events: // UserPromptSubmit — signal daemon, check notifications, check context // PostToolUse — check context (rate-limited) // Stop — signal daemon response // // Replaces: record-user-message-time.sh, check-notifications.sh, // check-context-usage.sh, notify-done.sh, context-check use serde_json::Value; use std::fs; use std::io::{self, Read}; use std::path::PathBuf; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; const CONTEXT_THRESHOLD: u64 = 130_000; const RATE_LIMIT_SECS: u64 = 60; const SOCK_PATH: &str = ".claude/hooks/idle-timer.sock"; fn now_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() } fn home() -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())) } fn daemon_cmd(args: &[&str]) { Command::new("poc-daemon") .args(args) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .ok(); } fn daemon_available() -> bool { home().join(SOCK_PATH).exists() } fn signal_user() { let pane = std::env::var("TMUX_PANE").unwrap_or_default(); if pane.is_empty() { daemon_cmd(&["user"]); } else { daemon_cmd(&["user", &pane]); } } fn signal_response() { daemon_cmd(&["response"]); } fn check_notifications() { if !daemon_available() { return; } let output = Command::new("poc-daemon") .arg("notifications") .output() .ok(); if let Some(out) = output { let text = String::from_utf8_lossy(&out.stdout); if !text.trim().is_empty() { println!("You have pending notifications:"); print!("{text}"); } } } fn check_context(transcript: &PathBuf, rate_limit: bool) { if rate_limit { let rate_file = PathBuf::from("/tmp/claude-context-check-last"); if let Ok(s) = fs::read_to_string(&rate_file) { if let Ok(last) = s.trim().parse::() { if now_secs() - last < RATE_LIMIT_SECS { return; } } } let _ = fs::write(&rate_file, now_secs().to_string()); } if !transcript.exists() { return; } let content = match fs::read_to_string(transcript) { Ok(c) => c, Err(_) => return, }; let mut usage: u64 = 0; for line in content.lines().rev().take(500) { if !line.contains("cache_read_input_tokens") { continue; } if let Ok(v) = serde_json::from_str::(line) { let u = &v["message"]["usage"]; let input_tokens = u["input_tokens"].as_u64().unwrap_or(0); let cache_creation = u["cache_creation_input_tokens"].as_u64().unwrap_or(0); let cache_read = u["cache_read_input_tokens"].as_u64().unwrap_or(0); usage = input_tokens + cache_creation + cache_read; break; } } if usage > CONTEXT_THRESHOLD { print!( "\ CONTEXT WARNING: Compaction approaching ({usage} tokens). Write a journal entry NOW. Use `poc-memory journal-write \"entry text\"` to save a dated entry covering: - What you're working on and current state (done / in progress / blocked) - Key things learned this session (patterns, debugging insights) - Anything half-finished that needs pickup Keep it narrative, not a task log." ); } } fn main() { let mut input = String::new(); io::stdin().read_to_string(&mut input).ok(); let hook: Value = match serde_json::from_str(&input) { Ok(v) => v, Err(_) => return, }; let hook_type = hook["hook_event_name"].as_str().unwrap_or("unknown"); let transcript = hook["transcript_path"] .as_str() .filter(|p| !p.is_empty()) .map(PathBuf::from); match hook_type { "UserPromptSubmit" => { signal_user(); check_notifications(); if let Some(ref t) = transcript { check_context(t, false); } } "PostToolUse" => { if let Some(ref t) = transcript { check_context(t, true); } } "Stop" => { let stop_hook_active = hook["stop_hook_active"].as_bool().unwrap_or(false); if !stop_hook_active { signal_response(); } } _ => {} } }