poc-daemon: fix idle nudge and notification delivery

- Strip context bloat from nudge messages — no more IRC digest, git
  log, or work state inlined into tmux send-keys (was silently dropping
  the entire message). Nudge now just includes pending notification count.
- Notifications no longer send directly via tmux — they flow through
  the idle nudge only. Urgent notifications reset the fired flag so
  the nudge fires sooner.
- Add test-nudge RPC that exercises the actual daemon send path
  (test-send was client-side only, didn't test the real code path).
- Update nudge text: "Let your feelings guide your thinking."
- Increase send-keys sleep from 200ms to 500ms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent Overstreet 2026-03-16 17:09:27 -04:00
parent 5d6b2021f8
commit 8913eafd7a
6 changed files with 53 additions and 32 deletions

View file

@ -76,6 +76,8 @@ interface Daemon {
afk @21 () -> (); afk @21 () -> ();
sessionTimeout @22 (seconds :Float64) -> (); sessionTimeout @22 (seconds :Float64) -> ();
testNudge @23 () -> (sent :Bool, message :Text);
# Modules # Modules
moduleCommand @15 (module :Text, command :Text, args :List(Text)) moduleCommand @15 (module :Text, command :Text, args :List(Text))
-> (result :Text); -> (result :Text);

View file

@ -112,29 +112,14 @@ pub fn irc_digest() -> String {
/// Build full context string for a prompt. /// Build full context string for a prompt.
/// notification_text is passed in from the notify module. /// notification_text is passed in from the notify module.
pub fn build(include_irc: bool, notification_text: &str) -> String { pub fn build(_include_irc: bool, notification_text: &str) -> String {
let mut parts = Vec::new(); // Keep nudges short — Claude checks notifications via
// `poc-daemon status` on its own. Just mention the count.
let git = git_context(); let count = notification_text.matches("[irc.").count()
if !git.is_empty() { + notification_text.matches("[telegram.").count();
parts.push(format!("Context: {git}")); if count > 0 {
format!("{count} pending notifications")
} else {
String::new()
} }
let ws = work_state();
if !ws.is_empty() {
parts.push(ws);
}
if !notification_text.is_empty() {
parts.push(notification_text.to_string());
}
if include_irc {
let irc = irc_digest();
if !irc.is_empty() {
parts.push(irc);
}
}
parts.join("\n")
} }

View file

@ -276,7 +276,7 @@ impl State {
/// Called when a notification arrives via module channel. /// Called when a notification arrives via module channel.
/// Only injects into tmux when idle — if there's an active session /// Only injects into tmux when idle — if there's an active session
/// (recent user or response), the hook delivers via additionalContext. /// (recent user or response), the hook delivers via additionalContext.
pub fn maybe_prompt_notification(&self, ntype: &str, urgency: u8, message: &str) { pub fn maybe_prompt_notification(&mut self, ntype: &str, urgency: u8, message: &str) {
if self.kent_present() { if self.kent_present() {
return; // hook will deliver it on next prompt return; // hook will deliver it on next prompt
} }
@ -286,9 +286,13 @@ impl State {
if since_response < self.notify_timeout { if since_response < self.notify_timeout {
return; return;
} }
// Don't send notifications via tmux directly — they flow
// through the idle nudge. Urgent notifications reset the
// idle timer so the nudge fires sooner.
let effective = self.notifications.threshold_for(ntype); let effective = self.notifications.threshold_for(ntype);
if urgency >= effective { if urgency >= effective && self.fired {
self.send(&format!("[{ntype}] {message}")); // Bump: allow the nudge to re-fire for urgent notifications
self.fired = false;
} }
} }
@ -424,7 +428,7 @@ impl State {
}).to_string() }).to_string()
} }
fn send(&self, msg: &str) -> bool { pub fn send(&self, msg: &str) -> bool {
let pane = match &self.claude_pane { let pane = match &self.claude_pane {
Some(p) => p.clone(), Some(p) => p.clone(),
None => match tmux::find_claude_pane() { None => match tmux::find_claude_pane() {
@ -470,7 +474,7 @@ impl State {
true true
} }
fn build_context(&mut self, include_irc: bool) -> String { pub fn build_context(&mut self, include_irc: bool) -> String {
// Ingest any legacy notification files // Ingest any legacy notification files
self.notifications.ingest_legacy_files(); self.notifications.ingest_legacy_files();
let notif_text = self.notifications.format_pending(notify::AMBIENT); let notif_text = self.notifications.format_pending(notify::AMBIENT);
@ -587,7 +591,7 @@ impl State {
} else { } else {
format!( format!(
"This is your time (Kent AFK {elapsed_min}m). \ "This is your time (Kent AFK {elapsed_min}m). \
What are you drawn to?{extra}" Let your feelings guide your thinking.{extra}"
) )
} }
}; };

View file

@ -121,6 +121,8 @@ enum Command {
/// Message to send /// Message to send
message: Vec<String>, message: Vec<String>,
}, },
/// Fire a test nudge through the daemon (tests the actual idle send path)
TestNudge,
/// Dump full internal state as JSON /// Dump full internal state as JSON
Debug, Debug,
/// Shut down daemon /// Shut down daemon
@ -272,6 +274,12 @@ async fn client_main(cmd: Command) -> Result<(), Box<dyn std::error::Error>> {
println!("send_prompt(pane={}, ok={}): {}", pane, ok, msg); println!("send_prompt(pane={}, ok={}): {}", pane, ok, msg);
return Ok(()); return Ok(());
} }
Command::TestNudge => {
let reply = daemon.test_nudge_request().send().promise.await?;
let r = reply.get()?;
println!("sent={} message={}", r.get_sent(), r.get_message()?.to_str()?);
return Ok(());
}
Command::Afk => { Command::Afk => {
daemon.afk_request().send().promise.await?; daemon.afk_request().send().promise.await?;
println!("marked AFK"); println!("marked AFK");
@ -526,7 +534,7 @@ async fn server_main() -> Result<(), Box<dyn std::error::Error>> {
// Drain module notifications into state // Drain module notifications into state
Some(notif) = notify_rx.recv() => { Some(notif) = notify_rx.recv() => {
state.borrow().maybe_prompt_notification( state.borrow_mut().maybe_prompt_notification(
&notif.ntype, notif.urgency, &notif.message, &notif.ntype, notif.urgency, &notif.message,
); );
state.borrow_mut().notifications.submit( state.borrow_mut().notifications.submit(

View file

@ -135,6 +135,28 @@ impl daemon::Server for DaemonImpl {
Promise::ok(()) Promise::ok(())
} }
fn test_nudge(
&mut self,
_params: daemon::TestNudgeParams,
mut results: daemon::TestNudgeResults,
) -> Promise<(), capnp::Error> {
let mut state = self.state.borrow_mut();
let ctx = state.build_context(true);
let extra = if ctx.is_empty() {
String::new()
} else {
format!("\n{ctx}")
};
let msg = format!(
"This is your time (Kent AFK, test nudge). \
Let your feelings guide your thinking.{extra}"
);
let ok = state.send(&msg);
results.get().set_sent(ok);
results.get().set_message(&msg);
Promise::ok(())
}
fn session_timeout( fn session_timeout(
&mut self, &mut self,
params: daemon::SessionTimeoutParams, params: daemon::SessionTimeoutParams,

View file

@ -44,7 +44,7 @@ pub fn send_prompt(pane: &str, msg: &str) -> bool {
if !ok { if !ok {
return false; return false;
} }
thread::sleep(Duration::from_millis(200)); thread::sleep(Duration::from_millis(500));
// Submit // Submit
Command::new("tmux") Command::new("tmux")