2026-03-05 19:17:22 -05:00
|
|
|
// 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;
|
|
|
|
|
|
2026-03-05 21:16:19 -05:00
|
|
|
// Defaults
|
|
|
|
|
const DEFAULT_PAUSE_SECS: f64 = 5.0 * 60.0;
|
2026-03-05 19:17:22 -05:00
|
|
|
const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0;
|
|
|
|
|
const DREAM_INTERVAL_HOURS: u64 = 18;
|
|
|
|
|
|
|
|
|
|
/// Persisted subset of daemon state — survives daemon restarts.
|
2026-03-05 21:16:19 -05:00
|
|
|
/// Includes both epoch floats (for computation) and ISO timestamps
|
|
|
|
|
/// (for human debugging via `cat daemon-state.json | jq`).
|
2026-03-05 19:17:22 -05:00
|
|
|
#[derive(Serialize, Deserialize, Default)]
|
|
|
|
|
struct Persisted {
|
|
|
|
|
last_user_msg: f64,
|
|
|
|
|
last_response: f64,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
sleep_until: Option<f64>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
claude_pane: Option<String>,
|
2026-03-05 21:16:19 -05:00
|
|
|
#[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,
|
2026-03-05 19:17:22 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn state_path() -> std::path::PathBuf {
|
|
|
|
|
home().join(".claude/hooks/daemon-state.json")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 21:16:19 -05:00
|
|
|
/// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 19:17:22 -05:00
|
|
|
#[derive(Serialize)]
|
|
|
|
|
pub struct State {
|
|
|
|
|
pub last_user_msg: f64,
|
|
|
|
|
pub last_response: f64,
|
|
|
|
|
pub claude_pane: Option<String>,
|
|
|
|
|
pub sleep_until: Option<f64>, // None=awake, 0=indefinite, >0=timestamp
|
|
|
|
|
pub quiet_until: f64,
|
|
|
|
|
pub consolidating: bool,
|
|
|
|
|
pub dreaming: bool,
|
|
|
|
|
pub dream_start: f64,
|
|
|
|
|
pub fired: bool,
|
2026-03-05 21:16:19 -05:00
|
|
|
pub idle_timeout: f64,
|
2026-03-05 19:17:22 -05:00
|
|
|
#[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,
|
2026-03-05 21:16:19 -05:00
|
|
|
idle_timeout: DEFAULT_PAUSE_SECS,
|
2026-03-05 19:17:22 -05:00
|
|
|
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::<Persisted>(&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;
|
2026-03-05 21:16:19 -05:00
|
|
|
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;
|
2026-03-05 19:17:22 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 21:16:19 -05:00
|
|
|
pub fn save(&self) {
|
2026-03-05 19:17:22 -05:00
|
|
|
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(),
|
2026-03-05 21:16:19 -05:00
|
|
|
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,
|
2026-03-05 19:17:22 -05:00
|
|
|
};
|
2026-03-05 21:16:19 -05:00
|
|
|
if let Ok(json) = serde_json::to_string_pretty(&p) {
|
2026-03-05 19:17:22 -05:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 21:16:19 -05:00
|
|
|
/// 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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 19:17:22 -05:00
|
|
|
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 {
|
2026-03-05 21:16:19 -05:00
|
|
|
(now() - self.last_user_msg) < SESSION_ACTIVE_SECS
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Full debug dump as JSON with computed values.
|
|
|
|
|
pub fn debug_json(&self) -> String {
|
2026-03-05 19:17:22 -05:00
|
|
|
let t = now();
|
2026-03-05 21:16:19 -05:00
|
|
|
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()
|
2026-03-05 19:17:22 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 => {
|
2026-03-05 21:16:19 -05:00
|
|
|
info!("send: no claude pane found");
|
2026-03-05 19:17:22 -05:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-05 21:16:19 -05:00
|
|
|
let ok = tmux::send_prompt(&pane, msg);
|
|
|
|
|
let preview: String = msg.chars().take(80).collect();
|
|
|
|
|
info!("send(pane={pane}, ok={ok}): {preview}");
|
|
|
|
|
ok
|
2026-03-05 19:17:22 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-05 21:16:19 -05:00
|
|
|
if elapsed < self.idle_timeout {
|
2026-03-05 19:17:22 -05:00
|
|
|
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 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::<f64>().ok());
|
|
|
|
|
|
|
|
|
|
match out {
|
|
|
|
|
Some(end_epoch) => ((now() - end_epoch) / 3600.0) as u64,
|
|
|
|
|
None => 999,
|
|
|
|
|
}
|
|
|
|
|
}
|