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:
parent
8662759d53
commit
eab656aa64
4 changed files with 207 additions and 30 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
¬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<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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue