// 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}; use consciousness::store; use consciousness_claude::hook as memory_search; const CONTEXT_THRESHOLD: u64 = 900_000; const RATE_LIMIT_SECS: u64 = 60; const SOCK_PATH: &str = ".consciousness/daemon.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() { let pane = std::env::var("TMUX_PANE").unwrap_or_default(); if pane.is_empty() { daemon_cmd(&["response"]); } else { daemon_cmd(&["response", &pane]); } } 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 for stale agent processes in a state dir. /// Cleans up pid files for dead processes and kills timed-out ones. /// Also detects PID reuse by checking if the process is actually a /// claude/poc-memory process (reads /proc/pid/cmdline). fn reap_agent_pids(state_dir: &std::path::Path, timeout_secs: u64) { let Ok(entries) = fs::read_dir(state_dir) else { return }; for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); let Some(pid_str) = name_str.strip_prefix("pid-") else { continue }; let Ok(pid) = pid_str.parse::() else { continue }; // Check if the process is actually alive if unsafe { libc::kill(pid, 0) } != 0 { fs::remove_file(entry.path()).ok(); continue; } // Check if the PID still belongs to a claude/poc-memory process. // PID reuse by an unrelated process would otherwise block the // agent from being re-launched. let is_ours = fs::read_to_string(format!("/proc/{}/cmdline", pid)) .map(|cmd| cmd.contains("claude") || cmd.contains("poc-memory")) .unwrap_or(false); if !is_ours { fs::remove_file(entry.path()).ok(); continue; } if timeout_secs > 0 { if let Ok(meta) = entry.metadata() { if let Ok(modified) = meta.modified() { if modified.elapsed().unwrap_or_default().as_secs() > timeout_secs { unsafe { libc::kill(pid, libc::SIGTERM); } fs::remove_file(entry.path()).ok(); } } } } } } /// Reap all agent output directories. fn reap_all_agents() { let agent_output = store::memory_dir().join("agent-output"); if let Ok(entries) = fs::read_dir(&agent_output) { for entry in entries.flatten() { if entry.file_type().map_or(false, |t| t.is_dir()) { reap_agent_pids(&entry.path(), 600); // 10 min timeout } } } } /// Check if enough new conversation has accumulated to trigger an observation run. fn maybe_trigger_observation(transcript: &PathBuf) { let cursor_file = 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 = dirs::home_dir().unwrap_or_default().join(".consciousness/cache/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(); reap_all_agents(); print!("{}", memory_search::run_hook(&input)); if let Some(ref t) = transcript { check_context(t, false); maybe_trigger_observation(t); } } "PostToolUse" => { print!("{}", memory_search::run_hook(&input)); 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(); } } _ => {} } }