state endpoint

This commit is contained in:
Brandon Thomas
2026-05-21 22:40:15 -04:00
parent a71c846634
commit d0c8dffecb
7 changed files with 218 additions and 18 deletions
@@ -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
@@ -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<AudioCommand>,
status: Arc<Mutex<InternalStatus>>,
}
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<String>,
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<u64>,
}
#[derive(Default)]
struct InternalStatus {
loop_playing: bool,
loop_name: Option<String>,
voices_active: u32,
current_stage: u32,
current_gap_millis: u64,
current_jitter_millis: u64,
loop_pool_size: u32,
loop_started: Option<Instant>,
}
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::<AudioCommand>();
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<AudioCommand>) {
fn run_audio_engine(rx: Receiver<AudioCommand>, status: Arc<Mutex<InternalStatus>>) {
let (_stream, stream_handle) = match OutputStream::try_default() {
Ok(s) => s,
Err(e) => {
@@ -121,42 +183,63 @@ fn run_audio_engine(rx: Receiver<AudioCommand>) {
}
}
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<Mutex<InternalStatus>>,
) -> 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<LoopController>) {
fn stop_current_loop(
current_loop: &mut Option<LoopController>,
status: &Arc<Mutex<InternalStatus>>,
) {
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<LoopController>) {
}
}
}
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<AtomicBool>,
status: Arc<Mutex<InternalStatus>>,
) {
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.
@@ -8,6 +8,7 @@ use crate::server_state::ServerState;
pub async fn loop_beep_handler(state: web::Data<ServerState>) -> 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<ServerState>) -> impl Responder
pub async fn loop_done_handler(state: web::Data<ServerState>) -> 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<ServerState>) -> impl Responder
pub async fn loop_await_handler(state: web::Data<ServerState>) -> 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<ServerState>) -> impl Responder
fn play_loop_or_404(
state: &ServerState,
name: &str,
primary: Option<PathBuf>,
extras: Vec<PathBuf>,
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(),
@@ -1,4 +1,5 @@
pub mod alert_handlers;
pub mod loop_handlers;
pub mod root_handler;
pub mod state_handler;
pub mod stop_handler;
@@ -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<ServerState>) -> 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<PathBuf>,
alert_done_sound: Option<PathBuf>,
alert_await_user_input_sound: Option<PathBuf>,
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(),
}
}
}
@@ -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)
@@ -68,6 +68,21 @@
</table>
</div>
<div class="group">
<h2>State <span class="pill">read-only</span></h2>
<table>
<tr><th>Endpoint</th><th>Behavior</th></tr>
<tr><td><code>GET /state</code></td><td>JSON snapshot of audio engine + config. Does not change playback.</td></tr>
</table>
<p style="color:#555;font-size:14px;">
The <code>audio</code> object shows the current loop (<code>loop_name</code> =
<em>beep</em> / <em>done</em> / <em>await</em>), 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 <code>config</code> object
mirrors the loaded YAML so you can verify it parsed correctly.
</p>
</div>
<div class="group">
<h2>Mixing rules</h2>
<ul>