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

@ -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 <type> <urgency> <message>
// poc-daemon notifications Get pending notifications
// poc-daemon notify-types List all notification types
// poc-daemon notify-threshold <type> <level>
// poc-daemon sleep [timestamp] Sleep (0 or omit = indefinite)
// poc-daemon wake Cancel sleep
// poc-daemon quiet [seconds] Suppress prompts
// poc-daemon irc <command> 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<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Start the daemon (foreground)
Daemon,
/// Query daemon status
Status,
/// Signal user activity
User {
/// tmux pane identifier
pane: Option<String>,
},
/// Signal Claude response
Response {
/// tmux pane identifier
pane: Option<String>,
},
/// Sleep (suppress idle timer). 0 or omit = indefinite
Sleep {
/// Wake timestamp (epoch seconds), 0 = indefinite
until: Option<f64>,
},
/// Cancel sleep
Wake,
/// Suppress prompts for N seconds (default 300)
Quiet {
/// Duration in seconds
seconds: Option<u32>,
},
/// 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<String>,
},
/// Get pending notifications
Notifications {
/// Minimum urgency filter
min_urgency: Option<String>,
},
/// 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<String>,
},
/// Telegram module commands
Telegram {
/// Subcommand
command: String,
/// Arguments
args: Vec<String>,
},
}
// ── Client mode ──────────────────────────────────────────────────
async fn client_main(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
async fn client_main(cmd: Command) -> Result<(), Box<dyn std::error::Error>> {
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<String>) -> Result<(), Box<dyn std::error::Error>
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 <seconds>")?;
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::<serde_json::Value>(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<String>) -> Result<(), Box<dyn std::error::Error>
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<String>) -> Result<(), Box<dyn std::error::Error>
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<String>) -> Result<(), Box<dyn std::error::Error>
}
}
}
"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} <command> [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<String>) -> Result<(), Box<dyn std::error::Error>
.await
}
async fn module_command(
daemon: &daemon_capnp::daemon::Client,
module: &str,
command: &str,
args: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
@ -440,33 +547,14 @@ async fn server_main() -> Result<(), Box<dyn std::error::Error>> {
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = 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 <type> <urgency> <message>");
eprintln!(" notifications [min_urgency]");
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);
match cli.command {
Some(Command::Daemon) => server_main().await,
Some(cmd) => client_main(cmd).await,
None => {
Cli::parse_from(["poc-daemon", "--help"]);
Ok(())
}
}
}