diff --git a/schema/daemon.capnp b/schema/daemon.capnp index 73003e6..1dc286f 100644 --- a/schema/daemon.capnp +++ b/schema/daemon.capnp @@ -61,6 +61,10 @@ interface Daemon { getTypes @13 () -> (types :List(TypeInfo)); setThreshold @14 (type :Text, level :UInt8) -> (); + idleTimeout @16 (seconds :Float64) -> (); + save @17 () -> (); + debug @18 () -> (json :Text); + # Modules moduleCommand @15 (module :Text, command :Text, args :List(Text)) -> (result :Text); diff --git a/src/bin/poc-daemon/idle.rs b/src/bin/poc-daemon/idle.rs index e854100..5848a87 100644 --- a/src/bin/poc-daemon/idle.rs +++ b/src/bin/poc-daemon/idle.rs @@ -12,12 +12,14 @@ use serde::{Deserialize, Serialize}; use std::fs; use tracing::info; -// Thresholds -const PAUSE_SECS: f64 = 5.0 * 60.0; +// Defaults +const DEFAULT_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. +/// Includes both epoch floats (for computation) and ISO timestamps +/// (for human debugging via `cat daemon-state.json | jq`). #[derive(Serialize, Deserialize, Default)] struct Persisted { last_user_msg: f64, @@ -26,12 +28,41 @@ struct Persisted { sleep_until: Option, #[serde(default)] claude_pane: Option, + #[serde(default)] + idle_timeout: f64, + // Human-readable mirrors — written but not consumed on load + #[serde(default, skip_deserializing)] + last_user_msg_time: String, + #[serde(default, skip_deserializing)] + last_response_time: String, + #[serde(default, skip_deserializing)] + saved_at: String, + #[serde(default, skip_deserializing)] + fired: bool, + #[serde(default, skip_deserializing)] + uptime: f64, } fn state_path() -> std::path::PathBuf { home().join(".claude/hooks/daemon-state.json") } +/// Format epoch seconds as a human-readable ISO-ish timestamp. +fn epoch_to_iso(epoch: f64) -> String { + if epoch == 0.0 { + return String::new(); + } + let secs = epoch as u64; + // Use date command — simple and correct for timezone + std::process::Command::new("date") + .args(["-d", &format!("@{secs}"), "+%Y-%m-%dT%H:%M:%S%z"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + #[derive(Serialize)] pub struct State { pub last_user_msg: f64, @@ -43,6 +74,7 @@ pub struct State { pub dreaming: bool, pub dream_start: f64, pub fired: bool, + pub idle_timeout: f64, #[serde(skip)] pub running: bool, #[serde(skip)] @@ -63,6 +95,7 @@ impl State { dreaming: false, dream_start: 0.0, fired: false, + idle_timeout: DEFAULT_PAUSE_SECS, running: true, start_time: now(), notifications: notify::NotifyState::new(), @@ -76,6 +109,12 @@ impl State { self.last_response = p.last_response; self.sleep_until = p.sleep_until; self.claude_pane = p.claude_pane; + if p.idle_timeout > 0.0 { + self.idle_timeout = p.idle_timeout; + } + // Suppress immediate fire after restart — wait for fresh + // user/response signal before allowing the idle timer + self.fired = true; } } @@ -90,14 +129,20 @@ impl State { ); } - fn save(&self) { + pub 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(), + last_user_msg_time: epoch_to_iso(self.last_user_msg), + last_response_time: epoch_to_iso(self.last_response), + saved_at: epoch_to_iso(now()), + fired: self.fired, + idle_timeout: self.idle_timeout, + uptime: now() - self.start_time, }; - if let Ok(json) = serde_json::to_string(&p) { + if let Ok(json) = serde_json::to_string_pretty(&p) { let _ = fs::write(state_path(), json); } } @@ -124,6 +169,32 @@ impl State { info!("response"); } + /// Check if a notification should trigger a tmux prompt. + /// Called when a notification arrives via module channel. + /// Only injects into tmux when idle — if there's an active session + /// (recent user or response), the hook delivers via additionalContext. + pub fn maybe_prompt_notification(&self, ntype: &str, urgency: u8, message: &str) { + if self.kent_present() { + return; // hook will deliver it on next prompt + } + // If we've responded recently, the session is active — + // notifications will arrive via hook, no need to wake us + let since_response = now() - self.last_response; + if since_response < self.idle_timeout { + return; + } + let effective = self.notifications.threshold_for(ntype); + if urgency >= effective { + self.send(&format!("[{ntype}] {message}")); + } + } + + pub fn handle_idle_timeout(&mut self, secs: f64) { + self.idle_timeout = secs; + self.save(); + info!("idle timeout = {secs}s"); + } + pub fn handle_sleep(&mut self, until: f64) { if until == 0.0 { self.sleep_until = Some(0.0); @@ -149,14 +220,68 @@ impl State { } pub fn kent_present(&self) -> bool { + (now() - self.last_user_msg) < SESSION_ACTIVE_SECS + } + + /// Full debug dump as JSON with computed values. + pub fn debug_json(&self) -> String { 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 + let reference = self.last_response.max(self.last_user_msg); + let since_user = t - self.last_user_msg; + let since_response = t - self.last_response; + let since_reference = if reference > 0.0 { t - reference } else { 0.0 }; + + let would_fire = !self.fired + && self.sleep_until.is_none() + && t >= self.quiet_until + && !self.consolidating + && !self.dreaming + && reference > 0.0 + && since_reference >= self.idle_timeout; + + let block_reason = if self.fired { + "already fired" + } else if self.sleep_until.is_some() { + "sleeping" + } else if t < self.quiet_until { + "quiet mode" + } else if self.consolidating { + "consolidating" + } else if self.dreaming { + "dreaming" + } else if reference == 0.0 { + "no activity yet" + } else if since_reference < self.idle_timeout { + "not idle long enough" + } else { + "none — would fire" + }; + + serde_json::json!({ + "now": t, + "uptime": t - self.start_time, + "idle_timeout": self.idle_timeout, + "last_user_msg": self.last_user_msg, + "last_user_msg_ago": since_user, + "last_user_msg_time": epoch_to_iso(self.last_user_msg), + "last_response": self.last_response, + "last_response_ago": since_response, + "last_response_time": epoch_to_iso(self.last_response), + "reference_ago": since_reference, + "kent_present": self.kent_present(), + "claude_pane": self.claude_pane, + "fired": self.fired, + "would_fire": would_fire, + "block_reason": block_reason, + "sleep_until": self.sleep_until, + "quiet_until": self.quiet_until, + "consolidating": self.consolidating, + "dreaming": self.dreaming, + "dream_start": self.dream_start, + "activity": format!("{:?}", self.notifications.activity), + "pending_notifications": self.notifications.pending.len(), + "notification_types": self.notifications.types.len(), + }).to_string() } fn send(&self, msg: &str) -> bool { @@ -165,13 +290,16 @@ impl State { None => match tmux::find_claude_pane() { Some(p) => p, None => { - info!("no claude pane found"); + info!("send: no claude pane found"); return false; } }, }; - tmux::send_prompt(&pane, msg) + 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 { @@ -270,7 +398,7 @@ impl State { } let elapsed = t - reference; - if elapsed < PAUSE_SECS { + if elapsed < self.idle_timeout { return Ok(()); } @@ -328,20 +456,6 @@ impl State { } -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) { diff --git a/src/bin/poc-daemon/main.rs b/src/bin/poc-daemon/main.rs index 7b64ca0..3880738 100644 --- a/src/bin/poc-daemon/main.rs +++ b/src/bin/poc-daemon/main.rs @@ -134,6 +134,15 @@ async fn client_main(args: Vec) -> Result<(), Box req.get().set_seconds(secs); req.send().promise.await?; } + "idle-timeout" => { + let secs: f64 = args.get(2) + .and_then(|s| s.parse().ok()) + .ok_or("usage: poc-daemon idle-timeout ")?; + let mut req = daemon.idle_timeout_request(); + req.get().set_seconds(secs); + req.send().promise.await?; + println!("idle timeout = {secs}s"); + } "consolidating" => { daemon.consolidating_request().send().promise.await?; } @@ -146,6 +155,20 @@ async fn client_main(args: Vec) -> Result<(), Box "dream-end" => { daemon.dream_end_request().send().promise.await?; } + "save" => { + daemon.save_request().send().promise.await?; + println!("state saved"); + } + "debug" => { + let reply = daemon.debug_request().send().promise.await?; + let json = reply.get()?.get_json()?.to_str()?; + // Pretty-print + if let Ok(v) = serde_json::from_str::(json) { + println!("{}", serde_json::to_string_pretty(&v).unwrap_or_else(|_| json.to_string())); + } else { + println!("{json}"); + } + } "stop" => { daemon.stop_request().send().promise.await?; println!("stopping"); @@ -350,6 +373,9 @@ async fn server_main() -> Result<(), Box> { // Drain module notifications into state Some(notif) = notify_rx.recv() => { + state.borrow().maybe_prompt_notification( + ¬if.ntype, notif.urgency, ¬if.message, + ); state.borrow_mut().notifications.submit( notif.ntype, notif.urgency, @@ -400,6 +426,7 @@ async fn server_main() -> Result<(), Box> { } } + state.borrow().save(); let _ = std::fs::remove_file(sock_path()); let _ = std::fs::remove_file(pid_path()); info!("daemon stopped"); @@ -422,8 +449,9 @@ async fn main() -> Result<(), Box> { match args[1].as_str() { "status" | "user" | "response" | "sleep" | "wake" | "quiet" | "consolidating" | "consolidated" | "dream-start" | "dream-end" - | "stop" | "notify" | "notifications" | "notify-types" - | "notify-threshold" | "irc" | "telegram" => client_main(args).await, + | "stop" | "save" | "notify" | "notifications" | "notify-types" + | "notify-threshold" | "idle-timeout" + | "irc" | "telegram" => client_main(args).await, _ => { eprintln!("usage: poc-daemon [command]"); eprintln!(" (no args) Start daemon"); @@ -435,6 +463,7 @@ async fn main() -> Result<(), Box> { eprintln!(" notify-types List notification types"); eprintln!(" notify-threshold "); eprintln!(" sleep [timestamp]"); + eprintln!(" save Force state persistence"); eprintln!(" wake / quiet / stop / dream-start / dream-end"); eprintln!(" irc [args]"); std::process::exit(1); diff --git a/src/bin/poc-daemon/rpc.rs b/src/bin/poc-daemon/rpc.rs index 1b2a9fd..724a2b1 100644 --- a/src/bin/poc-daemon/rpc.rs +++ b/src/bin/poc-daemon/rpc.rs @@ -126,6 +126,36 @@ impl daemon::Server for DaemonImpl { Promise::ok(()) } + fn idle_timeout( + &mut self, + params: daemon::IdleTimeoutParams, + _results: daemon::IdleTimeoutResults, + ) -> Promise<(), capnp::Error> { + let secs = pry!(params.get()).get_seconds(); + self.state.borrow_mut().handle_idle_timeout(secs); + Promise::ok(()) + } + + fn save( + &mut self, + _params: daemon::SaveParams, + _results: daemon::SaveResults, + ) -> Promise<(), capnp::Error> { + self.state.borrow().save(); + info!("state saved"); + Promise::ok(()) + } + + fn debug( + &mut self, + _params: daemon::DebugParams, + mut results: daemon::DebugResults, + ) -> Promise<(), capnp::Error> { + let json = self.state.borrow().debug_json(); + results.get().set_json(&json); + Promise::ok(()) + } + fn stop( &mut self, _params: daemon::StopParams,