diff --git a/crates/tools/servers/agent_notify_server/README.md b/crates/tools/servers/agent_notify_server/README.md index 1ae6471a54..de575f693c 100644 --- a/crates/tools/servers/agent_notify_server/README.md +++ b/crates/tools/servers/agent_notify_server/README.md @@ -29,6 +29,7 @@ The server listens on `127.0.0.1:43110` by default. Override with | GET | `/loop_done` | Loop `alert_done_sound`. Replaces any active loop. | | GET | `/loop_await` | Loop `alert_await_user_input_sound`. Replaces any active loop. | | GET | `/stop` | Stop everything — loops *and* queued one-shots. | +| GET | `/state` | Read-only JSON snapshot of the audio engine and loaded config. Does **not** change playback. | Mixing rules: @@ -37,6 +38,38 @@ Mixing rules: - `/stop` halts everything. - A missing config key makes the corresponding endpoint return `404`. +### `/state` response shape + +```json +{ + "audio": { + "loop_playing": true, + "loop_name": "done", + "voices_active": 2, + "current_stage": 1, + "current_gap_millis": 1000, + "current_jitter_millis": 750, + "loop_pool_size": 4, + "loop_uptime_secs": 17 + }, + "config": { + "alert_beep_sound": "…/test_beep.wav", + "alert_done_sound": "…/smrpg_flower.wav", + "alert_await_user_input_sound": "…/smrpg_ghost.wav", + "extra_alert_beep_count": 0, + "extra_alert_done_count": 3, + "extra_alert_await_count": 3, + "gap_schedule_millis": [2000, 1000, 500, 200], + "jitter_schedule_millis": [1500, 750, 400, 180], + "escalate_waits_secs": [10, 20, 30] + } +} +``` + +`loop_name` reflects which endpoint started the loop (`beep` / `done` / +`await`). When idle, `loop_playing` is `false` and the loop fields collapse +to zero/null. + ## Config `config/notify_config.yaml`: @@ -215,13 +248,15 @@ loop the `Notification` or `Stop` hook started. Why the notification wrapper? The `Notification` hook fires for *every* Claude Code notification — idle reminders, permission asks, background-agent -attention pings — not just "Claude needs your input". Without a filter, the -await sound plays minutes after the conversation has ended for unrelated -background events. `~/.claude/agent_notify_on_notification.sh` reads the hook -payload, logs every event to `/tmp/agent-notify-notifications.log` for -audit, and only fires `/loop_await` when the message text matches a -user-attention pattern (`waiting`, `needs your input`, `needs your -permission`, `needs your attention`). +attention pings — not just "Claude needs your input". Crucially, the idle +reminder uses the *same* `message` text as a real input prompt +("Claude is waiting for your input"), so the only reliable discriminator +is the `notification_type` field. +`~/.claude/agent_notify_on_notification.sh` reads the hook payload, logs +every event to `/tmp/agent-notify-notifications.log` for audit, and +skips events with `notification_type == "idle_prompt"`. Everything else +(permission prompts, unknown future types) fires `/loop_await` — better +to ring the bell on an unknown attention prompt than miss a real one. ### Replacing the old bash-loop system diff --git a/crates/tools/servers/agent_notify_server/src/audio_player.rs b/crates/tools/servers/agent_notify_server/src/audio_player.rs index a6ff1fc365..de6eac2541 100644 --- a/crates/tools/servers/agent_notify_server/src/audio_player.rs +++ b/crates/tools/servers/agent_notify_server/src/audio_player.rs @@ -25,6 +25,7 @@ use std::fs::File; use std::io::BufReader; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; use std::thread::{self, JoinHandle}; @@ -32,12 +33,14 @@ use std::time::{Duration, Instant}; use rand::Rng; use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink}; +use serde_derive::Serialize; const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50); #[derive(Clone)] pub struct AudioPlayerHandle { tx: Sender, + status: Arc>, } impl AudioPlayerHandle { @@ -56,10 +59,67 @@ impl AudioPlayerHandle { pub fn shutdown(&self) { let _ = self.tx.send(AudioCommand::Shutdown); } + + /// Snapshot of the engine state for the `/state` endpoint. Read-only — + /// does not change playback. + pub fn status(&self) -> EngineStatus { + let s = self.status.lock().unwrap_or_else(|e| e.into_inner()); + EngineStatus { + loop_playing: s.loop_playing, + loop_name: s.loop_name.clone(), + voices_active: s.voices_active, + current_stage: s.current_stage, + current_gap_millis: s.current_gap_millis, + current_jitter_millis: s.current_jitter_millis, + loop_pool_size: s.loop_pool_size, + loop_uptime_secs: s.loop_started.map(|t| t.elapsed().as_secs()), + } + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct EngineStatus { + pub loop_playing: bool, + pub loop_name: Option, + pub voices_active: u32, + pub current_stage: u32, + pub current_gap_millis: u64, + pub current_jitter_millis: u64, + pub loop_pool_size: u32, + pub loop_uptime_secs: Option, +} + +#[derive(Default)] +struct InternalStatus { + loop_playing: bool, + loop_name: Option, + voices_active: u32, + current_stage: u32, + current_gap_millis: u64, + current_jitter_millis: u64, + loop_pool_size: u32, + loop_started: Option, +} + +impl InternalStatus { + fn reset_loop(&mut self) { + self.loop_playing = false; + self.loop_name = None; + self.voices_active = 0; + self.current_stage = 0; + self.current_gap_millis = 0; + self.current_jitter_millis = 0; + self.loop_pool_size = 0; + self.loop_started = None; + } } #[derive(Clone, Debug)] pub struct LoopSpec { + /// Short tag identifying which endpoint started this loop ("beep" / + /// "done" / "await"). Surfaced in `/state` so callers can see what's + /// playing. + pub name: String, /// Ordered pool of sounds: primary first, then extras. The supervisor /// indexes this with `layer % pool.len()`, so when extras run out it /// cycles back through the pool (doubling already-playing voices). @@ -78,11 +138,13 @@ pub struct LoopSpec { pub fn spawn_audio_player() -> (AudioPlayerHandle, JoinHandle<()>) { let (tx, rx) = mpsc::channel::(); + let status = Arc::new(Mutex::new(InternalStatus::default())); + let status_for_engine = status.clone(); let thread = thread::Builder::new() .name("agent-notify-audio".to_string()) - .spawn(move || run_audio_engine(rx)) + .spawn(move || run_audio_engine(rx, status_for_engine)) .expect("spawn audio engine thread"); - (AudioPlayerHandle { tx }, thread) + (AudioPlayerHandle { tx, status }, thread) } enum AudioCommand { @@ -92,7 +154,7 @@ enum AudioCommand { Shutdown, } -fn run_audio_engine(rx: Receiver) { +fn run_audio_engine(rx: Receiver, status: Arc>) { let (_stream, stream_handle) = match OutputStream::try_default() { Ok(s) => s, Err(e) => { @@ -121,42 +183,63 @@ fn run_audio_engine(rx: Receiver) { } } AudioCommand::PlayLoop(spec) => { - stop_current_loop(&mut current_loop); + stop_current_loop(&mut current_loop, &status); if spec.pool.is_empty() { log::warn!("play_loop: empty sound pool, ignoring"); continue; } - current_loop = Some(start_loop_supervisor(&stream_handle, spec)); + current_loop = Some(start_loop_supervisor(&stream_handle, spec, status.clone())); } AudioCommand::StopAll => { - stop_current_loop(&mut current_loop); + stop_current_loop(&mut current_loop, &status); oneshot_sink.clear(); oneshot_sink.play(); } AudioCommand::Shutdown => { - stop_current_loop(&mut current_loop); + stop_current_loop(&mut current_loop, &status); oneshot_sink.stop(); return; } } } - stop_current_loop(&mut current_loop); + stop_current_loop(&mut current_loop, &status); oneshot_sink.stop(); } -fn start_loop_supervisor(stream_handle: &OutputStreamHandle, spec: LoopSpec) -> LoopController { +fn start_loop_supervisor( + stream_handle: &OutputStreamHandle, + spec: LoopSpec, + status: Arc>, +) -> LoopController { + // Initialize the visible status before any iterator starts, so a /state + // query racing the spawn sees the new loop, not the previous one's tail. + { + let mut s = status.lock().unwrap_or_else(|e| e.into_inner()); + s.loop_playing = true; + s.loop_name = Some(spec.name.clone()); + s.voices_active = 1; + s.current_stage = 0; + s.current_gap_millis = spec.gap_millis_schedule[0]; + s.current_jitter_millis = spec.jitter_millis_schedule[0]; + s.loop_pool_size = spec.pool.len() as u32; + s.loop_started = Some(Instant::now()); + } + let stop = Arc::new(AtomicBool::new(false)); let stop_for_thread = stop.clone(); let stream_handle = stream_handle.clone(); let thread = thread::Builder::new() .name("agent-notify-loop-supervisor".to_string()) - .spawn(move || run_loop_supervisor(stream_handle, spec, stop_for_thread)) + .spawn(move || run_loop_supervisor(stream_handle, spec, stop_for_thread, status)) .expect("spawn loop supervisor thread"); LoopController { stop, thread: Some(thread) } } -fn stop_current_loop(current_loop: &mut Option) { +fn stop_current_loop( + current_loop: &mut Option, + status: &Arc>, +) { if let Some(mut lc) = current_loop.take() { lc.stop.store(true, Ordering::SeqCst); if let Some(thread) = lc.thread.take() { @@ -165,12 +248,15 @@ fn stop_current_loop(current_loop: &mut Option) { } } } + let mut s = status.lock().unwrap_or_else(|e| e.into_inner()); + s.reset_loop(); } fn run_loop_supervisor( stream_handle: OutputStreamHandle, spec: LoopSpec, stop: Arc, + status: Arc>, ) { let pool_len = spec.pool.len(); if pool_len == 0 { @@ -234,6 +320,11 @@ fn run_loop_supervisor( stop.clone(), layer + 1, )); + let mut s = status.lock().unwrap_or_else(|e| e.into_inner()); + s.voices_active = iterators.len() as u32; + s.current_stage = (layer + 1) as u32; + s.current_gap_millis = next_gap; + s.current_jitter_millis = next_jitter; } // Wait until something signals stop, then drain iterator threads. diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs b/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs index d5afa89104..e4ec921623 100644 --- a/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs +++ b/crates/tools/servers/agent_notify_server/src/endpoints/loop_handlers.rs @@ -8,6 +8,7 @@ use crate::server_state::ServerState; pub async fn loop_beep_handler(state: web::Data) -> impl Responder { play_loop_or_404( &state, + "beep", state.config.alert_beep_sound.clone(), state.config.extra_alert_beep_sounds.clone(), "alert_beep_sound", @@ -17,6 +18,7 @@ pub async fn loop_beep_handler(state: web::Data) -> impl Responder pub async fn loop_done_handler(state: web::Data) -> impl Responder { play_loop_or_404( &state, + "done", state.config.alert_done_sound.clone(), state.config.extra_alert_done_sounds.clone(), "alert_done_sound", @@ -26,6 +28,7 @@ pub async fn loop_done_handler(state: web::Data) -> impl Responder pub async fn loop_await_handler(state: web::Data) -> impl Responder { play_loop_or_404( &state, + "await", state.config.alert_await_user_input_sound.clone(), state.config.extra_alert_await_sounds.clone(), "alert_await_user_input_sound", @@ -34,6 +37,7 @@ pub async fn loop_await_handler(state: web::Data) -> impl Responder fn play_loop_or_404( state: &ServerState, + name: &str, primary: Option, extras: Vec, config_key: &str, @@ -47,6 +51,7 @@ fn play_loop_or_404( pool.extend(extras); let spec = LoopSpec { + name: name.to_string(), pool, gap_millis_schedule: state.config.loop_gap_schedule_millis(), jitter_millis_schedule: state.config.loop_jitter_schedule_millis(), diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/mod.rs b/crates/tools/servers/agent_notify_server/src/endpoints/mod.rs index 374dd17914..8d13ad89c3 100644 --- a/crates/tools/servers/agent_notify_server/src/endpoints/mod.rs +++ b/crates/tools/servers/agent_notify_server/src/endpoints/mod.rs @@ -1,4 +1,5 @@ pub mod alert_handlers; pub mod loop_handlers; pub mod root_handler; +pub mod state_handler; pub mod stop_handler; diff --git a/crates/tools/servers/agent_notify_server/src/endpoints/state_handler.rs b/crates/tools/servers/agent_notify_server/src/endpoints/state_handler.rs new file mode 100644 index 0000000000..0fb714f510 --- /dev/null +++ b/crates/tools/servers/agent_notify_server/src/endpoints/state_handler.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; + +use actix_web::{HttpResponse, Responder, web}; +use serde_derive::Serialize; + +use crate::audio_player::EngineStatus; +use crate::server_state::ServerState; + +pub async fn state_handler(state: web::Data) -> impl Responder { + let resp = StateResponse { + audio: state.audio.status(), + config: ConfigSummary::from_state(&state), + }; + HttpResponse::Ok().json(resp) +} + +#[derive(Serialize)] +struct StateResponse { + audio: EngineStatus, + config: ConfigSummary, +} + +#[derive(Serialize)] +struct ConfigSummary { + alert_beep_sound: Option, + alert_done_sound: Option, + alert_await_user_input_sound: Option, + extra_alert_beep_count: usize, + extra_alert_done_count: usize, + extra_alert_await_count: usize, + gap_schedule_millis: [u64; 4], + jitter_schedule_millis: [u64; 4], + escalate_waits_secs: [u64; 3], +} + +impl ConfigSummary { + fn from_state(state: &ServerState) -> Self { + let c = &state.config; + Self { + alert_beep_sound: c.alert_beep_sound.clone(), + alert_done_sound: c.alert_done_sound.clone(), + alert_await_user_input_sound: c.alert_await_user_input_sound.clone(), + extra_alert_beep_count: c.extra_alert_beep_sounds.len(), + extra_alert_done_count: c.extra_alert_done_sounds.len(), + extra_alert_await_count: c.extra_alert_await_sounds.len(), + gap_schedule_millis: c.loop_gap_schedule_millis(), + jitter_schedule_millis: c.loop_jitter_schedule_millis(), + escalate_waits_secs: c.escalate_waits_secs(), + } + } +} diff --git a/crates/tools/servers/agent_notify_server/src/main.rs b/crates/tools/servers/agent_notify_server/src/main.rs index 527dea3945..538ef5a4aa 100644 --- a/crates/tools/servers/agent_notify_server/src/main.rs +++ b/crates/tools/servers/agent_notify_server/src/main.rs @@ -25,6 +25,7 @@ use crate::endpoints::loop_handlers::{ loop_await_handler, loop_beep_handler, loop_done_handler, }; use crate::endpoints::root_handler::root_handler; +use crate::endpoints::state_handler::state_handler; use crate::endpoints::stop_handler::stop_handler; use crate::server_state::ServerState; @@ -74,6 +75,7 @@ async fn main() -> anyhow::Result<()> { .route("/loop_done", web::get().to(loop_done_handler)) .route("/loop_await", web::get().to(loop_await_handler)) .route("/stop", web::get().to(stop_handler)) + .route("/state", web::get().to(state_handler)) }) .bind(&bind_address)? .workers(1) diff --git a/crates/tools/servers/agent_notify_server/static/index.html b/crates/tools/servers/agent_notify_server/static/index.html index af557b18cf..8e40012920 100644 --- a/crates/tools/servers/agent_notify_server/static/index.html +++ b/crates/tools/servers/agent_notify_server/static/index.html @@ -68,6 +68,21 @@ +
+

State read-only

+ + + +
EndpointBehavior
GET /stateJSON snapshot of audio engine + config. Does not change playback.
+

+ The audio object shows the current loop (loop_name = + beep / done / await), how many concurrent voices are + active, the current escalation stage, the gap and jitter being applied right + now, and how long the loop has been running. The config object + mirrors the loaded YAML so you can verify it parsed correctly. +

+
+

Mixing rules