// idle.rs — Claude Code idle timer // // Wraps the universal thalamus idle state machine with Claude-specific // functionality: tmux pane tracking, prompt injection, dream nudges, // and context building for autonomous nudges. use super::{context, tmux}; use crate::thalamus::{home, now, notify, idle as thalamus_idle}; use log::info; /// Claude Code idle state — wraps the universal state machine. pub struct State { pub inner: thalamus_idle::State, pub claude_pane: Option, } impl std::ops::Deref for State { type Target = thalamus_idle::State; fn deref(&self) -> &Self::Target { &self.inner } } impl std::ops::DerefMut for State { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } } impl State { pub fn new() -> Self { Self { inner: thalamus_idle::State::new(), claude_pane: None, } } pub fn load(&mut self) { self.inner.load(); // Also load claude_pane from persisted state let path = home().join(".consciousness/daemon-state.json"); if let Ok(data) = std::fs::read_to_string(&path) { if let Ok(v) = serde_json::from_str::(&data) { if let Some(p) = v.get("claude_pane").and_then(|v| v.as_str()) { self.claude_pane = Some(p.to_string()); } } } } pub fn save(&self) { self.inner.save(); } /// Record user activity with pane tracking. pub fn handle_user(&mut self, pane: &str) { self.claude_pane = Some(pane.to_string()); self.inner.user_activity(); } /// Record response activity with pane tracking. pub fn handle_response(&mut self, pane: &str) { self.claude_pane = Some(pane.to_string()); self.inner.response_activity(); } /// Maybe send a notification as a tmux prompt. pub fn maybe_prompt_notification(&mut self, ntype: &str, urgency: u8, _message: &str) { let threshold = self.inner.notifications.threshold_for(ntype); if urgency >= threshold { let deliverable = self.inner.notifications.drain_deliverable(); if !deliverable.is_empty() { let msgs: Vec = deliverable.iter() .map(|n| format!("[{}] {}", n.ntype, n.message)) .collect(); self.send(&msgs.join("\n")); } } } /// Send text to the Claude tmux pane. pub 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!("send: no claude pane found"); return false; } }, }; let ok = tmux::send_prompt(&pane, msg); let preview: String = msg.chars().take(80).collect(); info!("send(pane={pane}, ok={ok}): {preview}"); ok } fn check_dream_nudge(&self) -> bool { if !self.inner.dreaming || self.inner.dream_start == 0.0 { return false; } let minutes = (now() - self.inner.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 } pub fn build_context(&mut self, include_irc: bool) -> String { self.inner.notifications.ingest_legacy_files(); let notif_text = self.inner.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(); self.inner.decay_ewma(); self.inner.notifications.ingest_legacy_files(); // Find pane if we don't have one if self.claude_pane.is_none() { self.claude_pane = tmux::find_claude_pane(); } // Sleep mode if let Some(wake_at) = self.inner.sleep_until { if wake_at == 0.0 { return Ok(()); } if t < wake_at { return Ok(()); } info!("sleep expired, waking"); self.inner.sleep_until = None; self.inner.fired = false; self.inner.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 / consolidation / dream loop guards if t < self.inner.quiet_until { return Ok(()); } if self.inner.consolidating { return Ok(()); } if h.join(".consciousness/agents/dream-loop-active").exists() { return Ok(()); } if self.inner.dreaming { self.check_dream_nudge(); return Ok(()); } if self.inner.user_present() { return Ok(()); } if self.inner.in_turn { return Ok(()); } // Min nudge interval let since_nudge = t - self.inner.last_nudge; if since_nudge < thalamus_idle::MIN_NUDGE_INTERVAL { return Ok(()); } // Idle timeout check if !self.inner.should_go_idle() { return Ok(()); } // Transition to idle if self.inner.notifications.activity != notify::Activity::Idle { self.inner.notifications.set_activity(notify::Activity::Idle); } // Fire nudge let elapsed = self.inner.since_activity(); let elapsed_min = (elapsed / 60.0) as u64; let ctx = self.build_context(true); let extra = if ctx.is_empty() { String::new() } else { format!("\n{ctx}") }; let dream_hours = thalamus_idle::hours_since_last_dream(); let mut msg = format!( "This is your autonomous time (User AFK {elapsed_min}m). \ Keep doing what you're doing, or find something new to do"); if dream_hours >= thalamus_idle::DREAM_INTERVAL_HOURS { msg.push_str(&format!( " You haven't dreamed in {dream_hours} hours — \ consider running ~/.consciousness/tools/dream-start.sh \ and spending some time in dreaming mode. \ Or do whatever calls to you.")); } let msg = format!("{msg}{extra}"); if self.send(&msg) { self.inner.last_nudge = t; self.inner.fired = true; } Ok(()) } // Delegate common methods to inner pub fn handle_afk(&mut self) { self.inner.handle_afk(); } pub fn handle_session_timeout(&mut self, s: f64) { self.inner.handle_session_timeout(s); } pub fn handle_idle_timeout(&mut self, s: f64) { self.inner.handle_idle_timeout(s); } pub fn handle_ewma(&mut self, v: f64) -> f64 { self.inner.handle_ewma(v) } pub fn handle_notify_timeout(&mut self, s: f64) { self.inner.handle_notify_timeout(s); } pub fn handle_sleep(&mut self, until: f64) { self.inner.handle_sleep(until); } pub fn handle_wake(&mut self) { self.inner.handle_wake(); } pub fn handle_quiet(&mut self, seconds: u32) { self.inner.handle_quiet(seconds); } pub fn user_present(&self) -> bool { self.inner.user_present() } pub fn since_activity(&self) -> f64 { self.inner.since_activity() } pub fn block_reason(&self) -> &'static str { self.inner.block_reason() } pub fn debug_json(&self) -> String { // Add claude_pane to inner's json let mut v: serde_json::Value = serde_json::from_str(&self.inner.debug_json()) .unwrap_or_default(); if let Some(obj) = v.as_object_mut() { obj.insert("claude_pane".into(), serde_json::json!(self.claude_pane)); } v.to_string() } }