cli: switch to clap, add notify-timeout, improve status display

Replace manual arg parsing with clap derive for the full command set.
Single source of truth for command names, args, and help text.

Add notify_timeout (default 2min) — controls how long after last
response before notifications inject via tmux instead of waiting
for the hook. Separate from idle_timeout (5min) which controls
autonomous prompts.

Improve `poc-daemon status` to show both timers with elapsed/configured
and block reason, replacing the terse one-liner.

Add new Status fields over capnp: idleTimeout, notifyTimeout,
sinceActivity, sinceUser, blockReason.

ExecStart in poc-daemon.service now uses `daemon` subcommand.

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-03-05 21:32:27 -05:00
parent eab656aa64
commit d0080698f3
7 changed files with 416 additions and 170 deletions

View file

@ -13,7 +13,8 @@ use std::fs;
use tracing::info;
// Defaults
const DEFAULT_PAUSE_SECS: f64 = 5.0 * 60.0;
const DEFAULT_IDLE_TIMEOUT: f64 = 5.0 * 60.0;
const DEFAULT_NOTIFY_TIMEOUT: f64 = 2.0 * 60.0;
const SESSION_ACTIVE_SECS: f64 = 15.0 * 60.0;
const DREAM_INTERVAL_HOURS: u64 = 18;
@ -30,6 +31,8 @@ struct Persisted {
claude_pane: Option<String>,
#[serde(default)]
idle_timeout: f64,
#[serde(default)]
notify_timeout: f64,
// Human-readable mirrors — written but not consumed on load
#[serde(default, skip_deserializing)]
last_user_msg_time: String,
@ -75,6 +78,7 @@ pub struct State {
pub dream_start: f64,
pub fired: bool,
pub idle_timeout: f64,
pub notify_timeout: f64,
#[serde(skip)]
pub running: bool,
#[serde(skip)]
@ -95,7 +99,8 @@ impl State {
dreaming: false,
dream_start: 0.0,
fired: false,
idle_timeout: DEFAULT_PAUSE_SECS,
idle_timeout: DEFAULT_IDLE_TIMEOUT,
notify_timeout: DEFAULT_NOTIFY_TIMEOUT,
running: true,
start_time: now(),
notifications: notify::NotifyState::new(),
@ -112,6 +117,9 @@ impl State {
if p.idle_timeout > 0.0 {
self.idle_timeout = p.idle_timeout;
}
if p.notify_timeout > 0.0 {
self.notify_timeout = p.notify_timeout;
}
// Suppress immediate fire after restart — wait for fresh
// user/response signal before allowing the idle timer
self.fired = true;
@ -140,6 +148,7 @@ impl State {
saved_at: epoch_to_iso(now()),
fired: self.fired,
idle_timeout: self.idle_timeout,
notify_timeout: self.notify_timeout,
uptime: now() - self.start_time,
};
if let Ok(json) = serde_json::to_string_pretty(&p) {
@ -180,7 +189,7 @@ impl State {
// 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 {
if since_response < self.notify_timeout {
return;
}
let effective = self.notifications.threshold_for(ntype);
@ -195,6 +204,12 @@ impl State {
info!("idle timeout = {secs}s");
}
pub fn handle_notify_timeout(&mut self, secs: f64) {
self.notify_timeout = secs;
self.save();
info!("notify timeout = {secs}s");
}
pub fn handle_sleep(&mut self, until: f64) {
if until == 0.0 {
self.sleep_until = Some(0.0);
@ -223,23 +238,16 @@ impl State {
(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();
/// Seconds since the most recent of user message or response.
pub fn since_activity(&self) -> f64 {
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 };
if reference > 0.0 { now() - 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 {
/// Why the idle timer hasn't fired (or "none" if it would fire now).
pub fn block_reason(&self) -> &'static str {
let t = now();
if self.fired {
"already fired"
} else if self.sleep_until.is_some() {
"sleeping"
@ -249,30 +257,37 @@ impl State {
"consolidating"
} else if self.dreaming {
"dreaming"
} else if reference == 0.0 {
} else if self.last_response.max(self.last_user_msg) == 0.0 {
"no activity yet"
} else if since_reference < self.idle_timeout {
} else if self.since_activity() < self.idle_timeout {
"not idle long enough"
} else {
"none — would fire"
};
}
}
/// Full debug dump as JSON with computed values.
pub fn debug_json(&self) -> String {
let t = now();
let since_user = t - self.last_user_msg;
let since_response = t - self.last_response;
serde_json::json!({
"now": t,
"uptime": t - self.start_time,
"idle_timeout": self.idle_timeout,
"notify_timeout": self.notify_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,
"since_activity": self.since_activity(),
"kent_present": self.kent_present(),
"claude_pane": self.claude_pane,
"fired": self.fired,
"would_fire": would_fire,
"block_reason": block_reason,
"block_reason": self.block_reason(),
"sleep_until": self.sleep_until,
"quiet_until": self.quiet_until,
"consolidating": self.consolidating,