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

@ -112,29 +112,14 @@ pub fn irc_digest() -> String {
/// Build full context string for a prompt.
/// notification_text is passed in from the notify module.
pub fn build(include_irc: bool, notification_text: &str) -> String {
let mut parts = Vec::new();
let git = git_context();
if !git.is_empty() {
parts.push(format!("Context: {git}"));
pub fn build(_include_irc: bool, notification_text: &str) -> String {
// Keep nudges short — Claude checks notifications via
// `poc-daemon status` on its own. Just mention the count.
let count = notification_text.matches("[irc.").count()
+ notification_text.matches("[telegram.").count();
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.
/// 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) {
pub fn maybe_prompt_notification(&mut self, ntype: &str, urgency: u8, message: &str) {
if self.kent_present() {
return; // hook will deliver it on next prompt
}
@ -286,9 +286,13 @@ impl State {
if since_response < self.notify_timeout {
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);
if urgency >= effective {
self.send(&format!("[{ntype}] {message}"));
if urgency >= effective && self.fired {
// Bump: allow the nudge to re-fire for urgent notifications
self.fired = false;
}
}
@ -424,7 +428,7 @@ impl State {
}).to_string()
}
fn send(&self, msg: &str) -> bool {
pub fn send(&self, msg: &str) -> bool {
let pane = match &self.claude_pane {
Some(p) => p.clone(),
None => match tmux::find_claude_pane() {
@ -470,7 +474,7 @@ impl State {
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
self.notifications.ingest_legacy_files();
let notif_text = self.notifications.format_pending(notify::AMBIENT);
@ -587,7 +591,7 @@ impl State {
} else {
format!(
"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: Vec<String>,
},
/// Fire a test nudge through the daemon (tests the actual idle send path)
TestNudge,
/// Dump full internal state as JSON
Debug,
/// 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);
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 => {
daemon.afk_request().send().promise.await?;
println!("marked AFK");
@ -526,7 +534,7 @@ async fn server_main() -> Result<(), Box<dyn std::error::Error>> {
// Drain module notifications into state
Some(notif) = notify_rx.recv() => {
state.borrow().maybe_prompt_notification(
state.borrow_mut().maybe_prompt_notification(
&notif.ntype, notif.urgency, &notif.message,
);
state.borrow_mut().notifications.submit(

View file

@ -135,6 +135,28 @@ impl daemon::Server for DaemonImpl {
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(
&mut self,
params: daemon::SessionTimeoutParams,

View file

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