mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-25 10:39:37 +00:00
state endpoint
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user