// Idle timer module. // // Tracks user presence and Claude response times. When Claude has been // idle too long, sends a contextual prompt to the tmux pane. Handles // sleep mode, quiet mode, consolidation suppression, and dream nudges. // // Designed as the first "module" — future IRC/Telegram modules will // follow the same pattern: state + tick + handle_command. use crate::{context, home, now, notify, tmux}; use serde::{Deserialize, Serialize}; use std::fs; use tracing::info; // Thresholds const PAUSE_SECS: f64 = 5.0 * 60.0; const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0; const DREAM_INTERVAL_HOURS: u64 = 18; /// Persisted subset of daemon state — survives daemon restarts. #[derive(Serialize, Deserialize, Default)] struct Persisted { last_user_msg: f64, last_response: f64, #[serde(default)] sleep_until: Option, #[serde(default)] claude_pane: Option, } fn state_path() -> std::path::PathBuf { home().join(".claude/hooks/daemon-state.json") } #[derive(Serialize)] pub struct State { pub last_user_msg: f64, pub last_response: f64, pub claude_pane: Option, pub sleep_until: Option, // None=awake, 0=indefinite, >0=timestamp pub quiet_until: f64, pub consolidating: bool, pub dreaming: bool, pub dream_start: f64, pub fired: bool, #[serde(skip)] pub running: bool, #[serde(skip)] pub start_time: f64, #[serde(skip)] pub notifications: notify::NotifyState, } impl State { pub fn new() -> Self { Self { last_user_msg: 0.0, last_response: 0.0, claude_pane: None, sleep_until: None, quiet_until: 0.0, consolidating: false, dreaming: false, dream_start: 0.0, fired: false, running: true, start_time: now(), notifications: notify::NotifyState::new(), } } pub fn load(&mut self) { if let Ok(data) = fs::read_to_string(state_path()) { if let Ok(p) = serde_json::from_str::(&data) { self.last_user_msg = p.last_user_msg; self.last_response = p.last_response; self.sleep_until = p.sleep_until; self.claude_pane = p.claude_pane; } } // Always try to find the active pane if self.claude_pane.is_none() { self.claude_pane = tmux::find_claude_pane(); } info!( "loaded: user={:.0} resp={:.0} pane={:?} sleep={:?}", self.last_user_msg, self.last_response, self.claude_pane, self.sleep_until, ); } fn save(&self) { let p = Persisted { last_user_msg: self.last_user_msg, last_response: self.last_response, sleep_until: self.sleep_until, claude_pane: self.claude_pane.clone(), }; if let Ok(json) = serde_json::to_string(&p) { let _ = fs::write(state_path(), json); } } // Typed handlers for RPC pub fn handle_user(&mut self, pane: &str) { self.last_user_msg = now(); self.fired = false; if !pane.is_empty() { self.claude_pane = Some(pane.to_string()); } self.notifications.set_activity(notify::Activity::Focused); self.save(); info!("user (pane={})", if pane.is_empty() { "unchanged" } else { pane }); } pub fn handle_response(&mut self, pane: &str) { self.last_response = now(); self.fired = false; if !pane.is_empty() { self.claude_pane = Some(pane.to_string()); } self.save(); info!("response"); } pub fn handle_sleep(&mut self, until: f64) { if until == 0.0 { self.sleep_until = Some(0.0); info!("sleep indefinitely"); } else { self.sleep_until = Some(until); info!("sleep until {until}"); } self.notifications.set_activity(notify::Activity::Sleeping); self.save(); } pub fn handle_wake(&mut self) { self.sleep_until = None; self.fired = false; self.save(); info!("wake"); } pub fn handle_quiet(&mut self, seconds: u32) { self.quiet_until = now() + seconds as f64; info!("quiet {seconds}s"); } pub fn kent_present(&self) -> bool { let t = now(); if (t - self.last_user_msg) < SESSION_ACTIVE_SECS { return true; } if kb_idle_minutes() < (SESSION_ACTIVE_SECS / 60.0) { return true; } false } fn send(&self, msg: &str) -> bool { let pane = match &self.claude_pane { Some(p) => p.clone(), None => match tmux::find_claude_pane() { Some(p) => p, None => { info!("no claude pane found"); return false; } }, }; tmux::send_prompt(&pane, msg) } fn check_dream_nudge(&self) -> bool { if !self.dreaming || self.dream_start == 0.0 { return false; } let minutes = (now() - self.dream_start) / 60.0; if minutes >= 60.0 { self.send( "You've been dreaming for over an hour. Time to surface \ — run dream-end.sh and capture what you found.", ); } else if minutes >= 45.0 { self.send(&format!( "Dreaming for {:.0} minutes now. Start gathering your threads \ — you'll want to surface soon.", minutes )); } else if minutes >= 30.0 { self.send(&format!( "You've been dreaming for {:.0} minutes. \ No rush — just a gentle note from the clock.", minutes )); } else { return false; } true } fn build_context(&mut self, include_irc: bool) -> String { // Ingest any legacy notification files self.notifications.ingest_legacy_files(); let notif_text = self.notifications.format_pending(notify::AMBIENT); context::build(include_irc, ¬if_text) } pub async fn tick(&mut self) -> Result<(), String> { let t = now(); let h = home(); // Ingest legacy notification files every tick self.notifications.ingest_legacy_files(); // Sleep mode if let Some(wake_at) = self.sleep_until { if wake_at == 0.0 { return Ok(()); // indefinite } if t < wake_at { return Ok(()); } // Wake up info!("sleep expired, waking"); self.sleep_until = None; self.fired = false; self.save(); let ctx = self.build_context(true); let extra = if ctx.is_empty() { String::new() } else { format!("\n{ctx}") }; self.send(&format!( "Wake up. Read your journal (poc-memory journal-tail 10), \ check work-queue.md, and follow what calls to you.{extra}" )); return Ok(()); } // Quiet mode if t < self.quiet_until { return Ok(()); } // Consolidation if self.consolidating { return Ok(()); } // Dream loop (externally managed) if h.join(".claude/memory/dream-loop-active").exists() { return Ok(()); } // Dream nudges if self.dreaming { self.check_dream_nudge(); return Ok(()); } // Main idle check let reference = self.last_response.max(self.last_user_msg); if reference == 0.0 { return Ok(()); } let elapsed = t - reference; if elapsed < PAUSE_SECS { return Ok(()); } if self.fired { return Ok(()); } // Transition to idle — lower notification thresholds if self.notifications.activity != notify::Activity::Idle { self.notifications.set_activity(notify::Activity::Idle); } // Fire let elapsed_min = (elapsed / 60.0) as u64; let present = self.kent_present(); let ctx = self.build_context(!present); let extra = if ctx.is_empty() { String::new() } else { format!("\n{ctx}") }; let msg = if present { format!( "Let your feelings guide your thinking. \ What's interesting right now?{extra}" ) } else { let dream_hours = hours_since_last_dream(); if dream_hours >= DREAM_INTERVAL_HOURS { format!( "You're on your own (Kent AFK {elapsed_min}m). \ You haven't dreamed in {dream_hours} hours — \ consider running ~/.claude/tools/dream-start.sh \ and spending some time in dreaming mode. \ Or do whatever calls to you.{extra}" ) } else { format!( "You're on your own (Kent AFK {elapsed_min}m). \ Do whatever calls to you — work, read, think, \ chat on IRC, or rest. Check ~/.claude/memory/work-state \ for where you left off. Check work-queue.md \ if you want structure.{extra}" ) } }; if self.send(&msg) { self.fired = true; } Ok(()) } } fn kb_idle_minutes() -> f64 { let path = home().join(".claude/hooks/keyboard-idle-seconds"); match fs::read_to_string(path) { Ok(s) => { if let Ok(secs) = s.trim().parse::() { secs / 60.0 } else { 0.0 } } Err(_) => 0.0, } } fn hours_since_last_dream() -> u64 { let path = home().join(".claude/memory/dream-log.jsonl"); let content = match fs::read_to_string(path) { Ok(c) if !c.is_empty() => c, _ => return 999, }; let last_line = match content.lines().last() { Some(l) => l, None => return 999, }; let parsed: serde_json::Value = match serde_json::from_str(last_line) { Ok(v) => v, Err(_) => return 999, }; let end_str = match parsed.get("end").and_then(|v| v.as_str()) { Some(s) => s, None => return 999, }; // Parse ISO 8601 timestamp manually (avoid chrono dependency) // Format: "2025-03-04T10:30:00Z" or "2025-03-04T10:30:00+00:00" let end_str = end_str.replace('Z', "+00:00"); // Use the system date command as a simple parser let out = std::process::Command::new("date") .args(["-d", &end_str, "+%s"]) .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .and_then(|s| s.trim().parse::().ok()); match out { Some(end_epoch) => ((now() - end_epoch) / 3600.0) as u64, None => 999, } }