From d0080698f3833bba4e35a79329cd78f80b9c61c5 Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Thu, 5 Mar 2026 21:32:27 -0500 Subject: [PATCH] cli: switch to clap, add notify-timeout, improve status display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 121 +++++++++++++ Cargo.toml | 1 + schema/daemon.capnp | 30 ++-- src/bin/poc-daemon/idle.rs | 63 ++++--- src/bin/poc-daemon/main.rs | 354 +++++++++++++++++++++++-------------- src/bin/poc-daemon/rpc.rs | 15 ++ src/daemon.rs | 2 +- 7 files changed, 416 insertions(+), 170 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc133d4..38b67f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -260,6 +310,46 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + [[package]] name = "cmake" version = "0.1.57" @@ -269,6 +359,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1183,6 +1279,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.17" @@ -1471,6 +1573,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1597,6 +1705,7 @@ dependencies = [ "capnp-rpc", "capnpc", "chrono", + "clap", "faer", "futures", "jobkit", @@ -2304,6 +2413,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2742,6 +2857,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.21.0" diff --git a/Cargo.toml b/Cargo.toml index c9934f9..e6f60c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ serde_json = "1" bincode = "1" regex = "1" chrono = "0.4" +clap = { version = "4", features = ["derive"] } libc = "0.2" faer = "0.24.0" rkyv = { version = "0.7", features = ["validation", "std"] } diff --git a/schema/daemon.capnp b/schema/daemon.capnp index 1dc286f..87fef2e 100644 --- a/schema/daemon.capnp +++ b/schema/daemon.capnp @@ -27,18 +27,23 @@ enum Activity { } struct Status { - lastUserMsg @0 :Float64; - lastResponse @1 :Float64; - claudePane @2 :Text; - sleepUntil @3 :Float64; # 0 = not sleeping, -1 = indefinite - quietUntil @4 :Float64; - consolidating @5 :Bool; - dreaming @6 :Bool; - fired @7 :Bool; - kentPresent @8 :Bool; - uptime @9 :Float64; - activity @10 :Activity; - pendingCount @11 :UInt32; + lastUserMsg @0 :Float64; + lastResponse @1 :Float64; + claudePane @2 :Text; + sleepUntil @3 :Float64; # 0 = not sleeping, -1 = indefinite + quietUntil @4 :Float64; + consolidating @5 :Bool; + dreaming @6 :Bool; + fired @7 :Bool; + kentPresent @8 :Bool; + uptime @9 :Float64; + activity @10 :Activity; + pendingCount @11 :UInt32; + idleTimeout @12 :Float64; # configured idle timeout (secs) + notifyTimeout @13 :Float64; # configured notify-via-tmux timeout (secs) + sinceActivity @14 :Float64; # secs since max(lastUserMsg, lastResponse) + sinceUser @15 :Float64; # secs since lastUserMsg + blockReason @16 :Text; # why idle timer hasn't fired } interface Daemon { @@ -62,6 +67,7 @@ interface Daemon { setThreshold @14 (type :Text, level :UInt8) -> (); idleTimeout @16 (seconds :Float64) -> (); + notifyTimeout @19 (seconds :Float64) -> (); save @17 () -> (); debug @18 () -> (json :Text); diff --git a/src/bin/poc-daemon/idle.rs b/src/bin/poc-daemon/idle.rs index 5848a87..4960cdd 100644 --- a/src/bin/poc-daemon/idle.rs +++ b/src/bin/poc-daemon/idle.rs @@ -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, #[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, diff --git a/src/bin/poc-daemon/main.rs b/src/bin/poc-daemon/main.rs index 3880738..5716c11 100644 --- a/src/bin/poc-daemon/main.rs +++ b/src/bin/poc-daemon/main.rs @@ -4,21 +4,6 @@ // communication modules (IRC, Telegram) for Claude Code sessions. // Listens on a Unix domain socket with a Cap'n Proto RPC interface. // Same binary serves as both daemon and CLI client. -// -// Usage: -// poc-daemon Start the daemon -// poc-daemon status Query daemon status -// poc-daemon user [pane] Signal user activity -// poc-daemon response [pane] Signal Claude response -// poc-daemon notify -// poc-daemon notifications Get pending notifications -// poc-daemon notify-types List all notification types -// poc-daemon notify-threshold -// poc-daemon sleep [timestamp] Sleep (0 or omit = indefinite) -// poc-daemon wake Cancel sleep -// poc-daemon quiet [seconds] Suppress prompts -// poc-daemon irc IRC module commands -// poc-daemon stop Shut down daemon mod config; mod context; @@ -38,6 +23,7 @@ use std::rc::Rc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use capnp_rpc::{rpc_twoparty_capnp, twoparty, RpcSystem}; +use clap::{Parser, Subcommand}; use futures::AsyncReadExt; use tokio::net::UnixListener; use tracing::{error, info}; @@ -61,9 +47,111 @@ fn pid_path() -> PathBuf { home().join(".claude/hooks/idle-daemon.pid") } +// ── CLI ────────────────────────────────────────────────────────── + +#[derive(Parser)] +#[command(name = "poc-daemon", about = "Notification routing and idle management daemon")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Start the daemon (foreground) + Daemon, + /// Query daemon status + Status, + /// Signal user activity + User { + /// tmux pane identifier + pane: Option, + }, + /// Signal Claude response + Response { + /// tmux pane identifier + pane: Option, + }, + /// Sleep (suppress idle timer). 0 or omit = indefinite + Sleep { + /// Wake timestamp (epoch seconds), 0 = indefinite + until: Option, + }, + /// Cancel sleep + Wake, + /// Suppress prompts for N seconds (default 300) + Quiet { + /// Duration in seconds + seconds: Option, + }, + /// Set idle timeout in seconds (how long before autonomous prompt) + IdleTimeout { + /// Timeout in seconds + seconds: f64, + }, + /// Set notify timeout in seconds (how long before tmux notification injection) + NotifyTimeout { + /// Timeout in seconds + seconds: f64, + }, + /// Signal consolidation started + Consolidating, + /// Signal consolidation ended + Consolidated, + /// Signal dream started + DreamStart, + /// Signal dream ended + DreamEnd, + /// Force state persistence to disk + Save, + /// Dump full internal state as JSON + Debug, + /// Shut down daemon + Stop, + /// Submit a notification + Notify { + /// Notification type (e.g. "irc", "telegram") + #[arg(name = "type")] + ntype: String, + /// Urgency level (ambient/low/medium/high/critical or 0-4) + urgency: String, + /// Message text + message: Vec, + }, + /// Get pending notifications + Notifications { + /// Minimum urgency filter + min_urgency: Option, + }, + /// List all notification types + NotifyTypes, + /// Set notification threshold for a type + NotifyThreshold { + /// Notification type + #[arg(name = "type")] + ntype: String, + /// Urgency level threshold + level: String, + }, + /// IRC module commands + Irc { + /// Subcommand (join, leave, send, status, log, nick) + command: String, + /// Arguments + args: Vec, + }, + /// Telegram module commands + Telegram { + /// Subcommand + command: String, + /// Arguments + args: Vec, + }, +} + // ── Client mode ────────────────────────────────────────────────── -async fn client_main(args: Vec) -> Result<(), Box> { +async fn client_main(cmd: Command) -> Result<(), Box> { let sock = sock_path(); if !sock.exists() { eprintln!("daemon not running (no socket at {})", sock.display()); @@ -87,105 +175,126 @@ async fn client_main(args: Vec) -> Result<(), Box tokio::task::spawn_local(rpc_system); - let cmd = args.get(1).map(|s| s.as_str()).unwrap_or("status"); - match cmd { - "status" => { + Command::Daemon => unreachable!("handled in main"), + Command::Status => { let reply = daemon.status_request().send().promise.await?; - let status = reply.get()?.get_status()?; - println!( - "uptime={:.0}s pane={} kent={} activity={:?} pending={} fired={} sleep={} quiet={} dreaming={} consolidating={}", - status.get_uptime(), - status.get_claude_pane()?.to_str().unwrap_or("none"), - status.get_kent_present(), - status.get_activity()?, - status.get_pending_count(), - status.get_fired(), - status.get_sleep_until(), - status.get_quiet_until(), - status.get_dreaming(), - status.get_consolidating(), + let s = reply.get()?.get_status()?; + + let fmt_secs = |s: f64| -> String { + if s < 60.0 { format!("{:.0}s", s) } + else if s < 3600.0 { format!("{:.0}m", s / 60.0) } + else { format!("{:.1}h", s / 3600.0) } + }; + + println!("uptime: {} pane: {} activity: {:?} pending: {}", + fmt_secs(s.get_uptime()), + s.get_claude_pane()?.to_str().unwrap_or("none"), + s.get_activity()?, + s.get_pending_count(), ); + println!("idle timer: {}/{} ({})", + fmt_secs(s.get_since_activity()), + fmt_secs(s.get_idle_timeout()), + s.get_block_reason()?.to_str()?, + ); + println!("notify timer: {}/{}", + fmt_secs(s.get_since_activity()), + fmt_secs(s.get_notify_timeout()), + ); + println!("kent: {} (last {})", + if s.get_kent_present() { "present" } else { "away" }, + fmt_secs(s.get_since_user()), + ); + + let sleep = s.get_sleep_until(); + if sleep != 0.0 { + if sleep < 0.0 { + println!("sleep: indefinite"); + } else { + println!("sleep: until {sleep:.0}"); + } + } + if s.get_consolidating() { println!("consolidating"); } + if s.get_dreaming() { println!("dreaming"); } } - "user" => { - let pane = args.get(2).map(|s| s.as_str()).unwrap_or(""); + Command::User { pane } => { + let pane = pane.as_deref().unwrap_or(""); let mut req = daemon.user_request(); req.get().set_pane(pane); req.send().promise.await?; } - "response" => { - let pane = args.get(2).map(|s| s.as_str()).unwrap_or(""); + Command::Response { pane } => { + let pane = pane.as_deref().unwrap_or(""); let mut req = daemon.response_request(); req.get().set_pane(pane); req.send().promise.await?; } - "sleep" => { - let until: f64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0.0); + Command::Sleep { until } => { let mut req = daemon.sleep_request(); - req.get().set_until(until); + req.get().set_until(until.unwrap_or(0.0)); req.send().promise.await?; } - "wake" => { + Command::Wake => { daemon.wake_request().send().promise.await?; } - "quiet" => { - let secs: u32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(300); + Command::Quiet { seconds } => { let mut req = daemon.quiet_request(); - req.get().set_seconds(secs); + req.get().set_seconds(seconds.unwrap_or(300)); 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 ")?; + Command::IdleTimeout { seconds } => { let mut req = daemon.idle_timeout_request(); - req.get().set_seconds(secs); + req.get().set_seconds(seconds); req.send().promise.await?; - println!("idle timeout = {secs}s"); + println!("idle timeout = {seconds}s"); } - "consolidating" => { + Command::NotifyTimeout { seconds } => { + let mut req = daemon.notify_timeout_request(); + req.get().set_seconds(seconds); + req.send().promise.await?; + println!("notify timeout = {seconds}s"); + } + Command::Consolidating => { daemon.consolidating_request().send().promise.await?; } - "consolidated" => { + Command::Consolidated => { daemon.consolidated_request().send().promise.await?; } - "dream-start" => { + Command::DreamStart => { daemon.dream_start_request().send().promise.await?; } - "dream-end" => { + Command::DreamEnd => { daemon.dream_end_request().send().promise.await?; } - "save" => { + Command::Save => { daemon.save_request().send().promise.await?; println!("state saved"); } - "debug" => { + Command::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" => { + Command::Stop => { daemon.stop_request().send().promise.await?; println!("stopping"); } - "notify" => { - let ntype = args.get(2).ok_or("missing type")?; - let urgency_str = args.get(3).ok_or("missing urgency")?; - let urgency = notify::parse_urgency(urgency_str) - .ok_or_else(|| format!("invalid urgency: {urgency_str}"))?; - let message = args[4..].join(" "); + Command::Notify { ntype, urgency, message } => { + let urgency = notify::parse_urgency(&urgency) + .ok_or_else(|| format!("invalid urgency: {urgency}"))?; + let message = message.join(" "); if message.is_empty() { return Err("missing message".into()); } let mut req = daemon.notify_request(); let mut n = req.get().init_notification(); - n.set_type(ntype); + n.set_type(&ntype); n.set_urgency(urgency); n.set_message(&message); n.set_timestamp(crate::now()); @@ -196,10 +305,10 @@ async fn client_main(args: Vec) -> Result<(), Box println!("queued"); } } - "notifications" => { - let min: u8 = args - .get(2) - .and_then(|s| notify::parse_urgency(s)) + Command::Notifications { min_urgency } => { + let min: u8 = min_urgency + .as_deref() + .and_then(notify::parse_urgency) .unwrap_or(255); let mut req = daemon.get_notifications_request(); @@ -207,18 +316,16 @@ async fn client_main(args: Vec) -> Result<(), Box let reply = req.send().promise.await?; let list = reply.get()?.get_notifications()?; - if !list.is_empty() { - for n in list.iter() { - println!( - "[{}:{}] {}", - n.get_type()?.to_str()?, - notify::urgency_name(n.get_urgency()), - n.get_message()?.to_str()?, - ); - } + for n in list.iter() { + println!( + "[{}:{}] {}", + n.get_type()?.to_str()?, + notify::urgency_name(n.get_urgency()), + n.get_message()?.to_str()?, + ); } } - "notify-types" => { + Command::NotifyTypes => { let reply = daemon.get_types_request().send().promise.await?; let list = reply.get()?.get_types()?; @@ -240,42 +347,21 @@ async fn client_main(args: Vec) -> Result<(), Box } } } - "notify-threshold" => { - let ntype = args.get(2).ok_or("missing type")?; - let level_str = args.get(3).ok_or("missing level")?; - let level = notify::parse_urgency(level_str) - .ok_or_else(|| format!("invalid level: {level_str}"))?; + Command::NotifyThreshold { ntype, level } => { + let level = notify::parse_urgency(&level) + .ok_or_else(|| format!("invalid level: {level}"))?; let mut req = daemon.set_threshold_request(); - req.get().set_type(ntype); + req.get().set_type(&ntype); req.get().set_level(level); req.send().promise.await?; println!("{ntype} threshold={}", notify::urgency_name(level)); } - // Module commands: "irc join #foo", "telegram send hello" - "irc" | "telegram" => { - let module = cmd; - let module_cmd = args.get(2).ok_or( - format!("usage: poc-daemon {module} [args...]"), - )?; - let module_args: Vec<&str> = args[3..].iter().map(|s| s.as_str()).collect(); - - let mut req = daemon.module_command_request(); - req.get().set_module(module); - req.get().set_command(module_cmd); - let mut args_builder = req.get().init_args(module_args.len() as u32); - for (i, a) in module_args.iter().enumerate() { - args_builder.set(i as u32, a); - } - let reply = req.send().promise.await?; - let result = reply.get()?.get_result()?.to_str()?; - if !result.is_empty() { - println!("{result}"); - } + Command::Irc { command, args } => { + module_command(&daemon, "irc", &command, &args).await?; } - _ => { - eprintln!("unknown command: {cmd}"); - std::process::exit(1); + Command::Telegram { command, args } => { + module_command(&daemon, "telegram", &command, &args).await?; } } @@ -284,6 +370,27 @@ async fn client_main(args: Vec) -> Result<(), Box .await } +async fn module_command( + daemon: &daemon_capnp::daemon::Client, + module: &str, + command: &str, + args: &[String], +) -> Result<(), Box> { + let mut req = daemon.module_command_request(); + req.get().set_module(module); + req.get().set_command(command); + let mut args_builder = req.get().init_args(args.len() as u32); + for (i, a) in args.iter().enumerate() { + args_builder.set(i as u32, a); + } + let reply = req.send().promise.await?; + let result = reply.get()?.get_result()?.to_str()?; + if !result.is_empty() { + println!("{result}"); + } + Ok(()) +} + // ── Server mode ────────────────────────────────────────────────── async fn server_main() -> Result<(), Box> { @@ -440,33 +547,14 @@ async fn server_main() -> Result<(), Box> { #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { - let args: Vec = std::env::args().collect(); + let cli = Cli::parse(); - if args.len() < 2 { - return server_main().await; - } - - match args[1].as_str() { - "status" | "user" | "response" | "sleep" | "wake" | "quiet" - | "consolidating" | "consolidated" | "dream-start" | "dream-end" - | "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"); - eprintln!(" status Query daemon status"); - eprintln!(" user [pane] Signal user activity"); - eprintln!(" response [pane] Signal Claude response"); - eprintln!(" notify "); - eprintln!(" notifications [min_urgency]"); - 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); + match cli.command { + Some(Command::Daemon) => server_main().await, + Some(cmd) => client_main(cmd).await, + None => { + Cli::parse_from(["poc-daemon", "--help"]); + Ok(()) } } } diff --git a/src/bin/poc-daemon/rpc.rs b/src/bin/poc-daemon/rpc.rs index 724a2b1..08f73f8 100644 --- a/src/bin/poc-daemon/rpc.rs +++ b/src/bin/poc-daemon/rpc.rs @@ -136,6 +136,16 @@ impl daemon::Server for DaemonImpl { Promise::ok(()) } + fn notify_timeout( + &mut self, + params: daemon::NotifyTimeoutParams, + _results: daemon::NotifyTimeoutResults, + ) -> Promise<(), capnp::Error> { + let secs = pry!(params.get()).get_seconds(); + self.state.borrow_mut().handle_notify_timeout(secs); + Promise::ok(()) + } + fn save( &mut self, _params: daemon::SaveParams, @@ -196,6 +206,11 @@ impl daemon::Server for DaemonImpl { notify::Activity::Sleeping => crate::daemon_capnp::Activity::Sleeping, }); status.set_pending_count(s.notifications.pending.len() as u32); + status.set_idle_timeout(s.idle_timeout); + status.set_notify_timeout(s.notify_timeout); + status.set_since_activity(s.since_activity()); + status.set_since_user(crate::now() - s.last_user_msg); + status.set_block_reason(s.block_reason()); Promise::ok(()) } diff --git a/src/daemon.rs b/src/daemon.rs index c49bc09..e72e54c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -679,7 +679,7 @@ After=default.target [Service] Type=simple -ExecStart={exe} +ExecStart={exe} daemon Restart=on-failure RestartSec=10 Environment=HOME={home}