2026-03-05 19:17:22 -05:00
|
|
|
// 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())
|
2026-03-05 21:43:04 -05:00
|
|
|
.status()
|
2026-03-05 19:17:22 -05:00
|
|
|
.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::<u64>() {
|
|
|
|
|
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::<Value>(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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-05 21:43:04 -05:00
|
|
|
let hook_type = hook["hook_event_name"].as_str().unwrap_or("unknown");
|
2026-03-05 19:17:22 -05:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|