// 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 = 900_000; const RATE_LIMIT_SECS: u64 = 60; const SOCK_PATH: &str = ".claude/hooks/idle-timer.sock"; /// How many bytes of new transcript before triggering an observation run. /// Override with POC_OBSERVATION_THRESHOLD env var. /// Default: 20KB ≈ 5K tokens. The observation agent's chunk_size (in .agent /// file) controls how much context it actually reads. fn observation_threshold() -> u64 { std::env::var("POC_OBSERVATION_THRESHOLD") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(20_000) } 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}"); } } } /// Check if enough new conversation has accumulated to trigger an observation run. fn maybe_trigger_observation(transcript: &PathBuf) { let cursor_file = poc_memory::store::memory_dir().join("observation-cursor"); let last_pos: u64 = fs::read_to_string(&cursor_file) .ok() .and_then(|s| s.trim().parse().ok()) .unwrap_or(0); let current_size = transcript.metadata() .map(|m| m.len()) .unwrap_or(0); if current_size > last_pos + observation_threshold() { // Queue observation via daemon RPC let _ = Command::new("poc-memory") .args(["agent", "daemon", "run", "observation", "1"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn(); eprintln!("[poc-hook] observation triggered ({} new bytes)", current_size - last_pos); // Update cursor to current position let _ = fs::write(&cursor_file, current_size.to_string()); } } 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); // Daemon agent calls set POC_AGENT=1 — skip all signaling. // Without this, the daemon's claude -p calls trigger hooks that // signal "user active", keeping the idle timer permanently reset. if std::env::var("POC_AGENT").is_ok() { return; } match hook_type { "UserPromptSubmit" => { signal_user(); check_notifications(); // Run memory-search, passing through the hook input it needs if let Ok(output) = Command::new("memory-search") .arg("--hook") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() .and_then(|mut child| { if let Some(ref mut stdin) = child.stdin { use std::io::Write; let _ = stdin.write_all(input.as_bytes()); } child.wait_with_output() }) { let text = String::from_utf8_lossy(&output.stdout); if !text.is_empty() { print!("{text}"); } } if let Some(ref t) = transcript { check_context(t, false); maybe_trigger_observation(t); } } "PostToolUse" => { // Drip-feed pending context chunks from initial load if let Ok(output) = Command::new("memory-search") .arg("--hook") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) .spawn() .and_then(|mut child| { if let Some(ref mut stdin) = child.stdin { use std::io::Write; let _ = stdin.write_all(input.as_bytes()); } child.wait_with_output() }) { let text = String::from_utf8_lossy(&output.stdout); if !text.is_empty() { print!("{text}"); } } 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(); } } _ => {} } }