idle: persist timeout, suppress restart fire, add debug/save/idle-timeout commands

Several idle timer fixes and new inspection capabilities:

- Persist idle_timeout across daemon restarts (was reverting to 5min default)
- Set fired=true on load to suppress immediate fire from stale timestamps
- Add human-readable ISO timestamps to daemon-state.json for debugging
- Use to_string_pretty for readable state file
- Make save() public for RPC access
- Remove kb_idle_minutes() — go purely off message timestamps
- Add maybe_prompt_notification() with idle gate so notifications only
  inject via tmux when truly idle, not during active sessions
- Add debug_json() for full state inspection with computed values
  (would_fire, block_reason, all timers)

New RPC commands (schema @16-18):
  poc-daemon idle-timeout <secs>  — set idle timeout
  poc-daemon save                 — force state persistence
  poc-daemon debug                — dump full internal state as JSON

Also: save state on clean shutdown, route module notifications through
maybe_prompt_notification before submitting to queue.

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-03-05 21:16:19 -05:00 committed by Kent Overstreet
parent 8662759d53
commit eab656aa64
4 changed files with 207 additions and 30 deletions

View file

@ -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<f64>,
#[serde(default)]
claude_pane: Option<String>,
#[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::<f64>() {
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) {

View file

@ -134,6 +134,15 @@ async fn client_main(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>
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 <seconds>")?;
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<String>) -> Result<(), Box<dyn std::error::Error>
"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::<serde_json::Value>(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<dyn std::error::Error>> {
// Drain module notifications into state
Some(notif) = notify_rx.recv() => {
state.borrow().maybe_prompt_notification(
&notif.ntype, notif.urgency, &notif.message,
);
state.borrow_mut().notifications.submit(
notif.ntype,
notif.urgency,
@ -400,6 +426,7 @@ async fn server_main() -> Result<(), Box<dyn std::error::Error>> {
}
}
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
eprintln!(" notify-types List notification types");
eprintln!(" notify-threshold <type> <level>");
eprintln!(" sleep [timestamp]");
eprintln!(" save Force state persistence");
eprintln!(" wake / quiet / stop / dream-start / dream-end");
eprintln!(" irc <join|leave|send|status|log|nick> [args]");
std::process::exit(1);

View file

@ -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,