From 51e358ee2cbe3fa9a0caaba8a112ab7b6479aa17 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 24 Apr 2026 11:43:03 -0700 Subject: [PATCH] slab: Notification hook fades ambient + TTS "help me" When Claude pauses for user feedback (permission prompt / idle input) the reactive listener + synth fade via SIGTERM and `say` delivers "help me". A /tmp/slab-ambient-paused flag keeps lid-ambient.sh from re-arming on its next 0.5s tick; it's cleared on the next UserPromptSubmit or Stop. The existing "i'm tired" Stop path is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- slab/bin/claude-help.py | 74 +++++++++++++++++++++++++++++++++++ slab/bin/claude-notify.sh | 51 ++++++++++++++++++++++++ slab/bin/claude-prompt-log.sh | 4 ++ slab/bin/claude-stop.sh | 2 +- slab/bin/lid-ambient.sh | 14 +++++-- slab/settings-fragment.json | 7 ++++ 6 files changed, 148 insertions(+), 4 deletions(-) create mode 100755 slab/bin/claude-help.py create mode 100755 slab/bin/claude-notify.sh diff --git a/slab/bin/claude-help.py b/slab/bin/claude-help.py new file mode 100755 index 0000000000..c21078ecd3 --- /dev/null +++ b/slab/bin/claude-help.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Notification stinger: speak "help me" with a short cosine fade-out. + +Invoked from claude-notify.sh when Claude Code emits a Notification hook — +i.e. the agent has paused and is waiting on user feedback (permission prompt +or idle input). Unlike claude-tired.py, this is an attention cue, not a +lullaby: the delivery is brisker and the tail silence is small. Ambient has +already been asked to fade via SIGTERM by the caller, so this phrase plays +over the dissolving bed. +""" + +import os +import subprocess +import sys +import tempfile +import wave + +import numpy as np +import sounddevice as sd + +TEXT = "help me" +RATE = 175 # brisker than the sleep stinger — it's a request, not a sigh +HOLD_FRAC = 0.55 # hold more of the phrase at full volume before tapering +TAIL_SILENCE_S = 0.2 # short pad; we're not about to sleep the machine + + +def synth(): + with tempfile.TemporaryDirectory() as tmp: + aiff = os.path.join(tmp, 'help.aiff') + wav = os.path.join(tmp, 'help.wav') + subprocess.run( + ['/usr/bin/say', '-r', str(RATE), '-o', aiff, TEXT], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + subprocess.run( + ['/usr/bin/afconvert', '-f', 'WAVE', '-d', 'LEI16@22050', aiff, wav], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + with wave.open(wav, 'rb') as wf: + sr = wf.getframerate() + nch = wf.getnchannels() + raw = wf.readframes(wf.getnframes()) + audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 + if nch > 1: + audio = audio.reshape(-1, nch).mean(axis=1) + return audio, sr + + +def envelope(audio, sr): + n = audio.size + if n == 0: + return audio + hold = int(n * HOLD_FRAC) + env = np.ones(n, dtype=np.float32) + fade_n = n - hold + if fade_n > 0: + t = np.linspace(0.0, 1.0, fade_n, dtype=np.float32) + env[hold:] = np.cos(t * np.pi * 0.5) ** 2 + tail = np.zeros(int(sr * TAIL_SILENCE_S), dtype=np.float32) + return np.concatenate([audio * env, tail]) + + +def main(): + try: + audio, sr = synth() + except (subprocess.CalledProcessError, FileNotFoundError) as e: + print(f"claude-help: tts failed: {e}", file=sys.stderr) + return 1 + sd.play(envelope(audio, sr), sr, blocking=True) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/slab/bin/claude-notify.sh b/slab/bin/claude-notify.sh new file mode 100755 index 0000000000..d947721e44 --- /dev/null +++ b/slab/bin/claude-notify.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Claude Notification hook. Fires when the agent pauses and needs user +# feedback (permission prompt or idle-waiting-on-input). We: +# 1. Fade the ambient bed (SIGTERM the reactive listener + synth). +# 2. Drop a pause flag so the lid-ambient daemon's polling loop does NOT +# auto-restart ambient while we wait on the user — the active marker +# for the prompt is still present, so without this flag the daemon +# would re-arm on the next 0.5s tick. +# 3. Speak "help me" with a short fade tail (claude-help.py). +# The pause flag is cleared by the next UserPromptSubmit (user responded) +# or by Stop (work ended). Does NOT sleep the Mac. +set -u +SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} +SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} +LOG=${CLAUDE_NOTIFY_LOG:-$SLAB_HOME/logs/claude-notify.log} +PAUSE_FLAG=/tmp/slab-ambient-paused +mkdir -p "$(dirname "$LOG")" + +input=$(cat) +session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null) +message=$(echo "$input" | jq -r '.message // empty' 2>/dev/null) +echo "$(date '+%Y-%m-%d %H:%M:%S') Notification: session=${session_id:-?} msg=${message:-?}" >> "$LOG" + +# Mark paused before fading so the daemon sees the flag on its next tick. +: > "$PAUSE_FLAG" + +# Fade ambient (mirrors stop_ambient in claude-stop.sh, minus the afplay kill — +# short chimes like the start stinger can finish naturally). +if [[ -f /tmp/lidreactive.pid ]]; then + pid=$(cat /tmp/lidreactive.pid 2>/dev/null) + [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null + rm -f /tmp/lidreactive.pid +else + pkill -TERM -f lid-reactive.py 2>/dev/null +fi +if [[ -f /tmp/lidsynth.pid ]]; then + pid=$(cat /tmp/lidsynth.pid 2>/dev/null) + [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null + rm -f /tmp/lidsynth.pid +else + pkill -TERM -f lid-ambient-synth 2>/dev/null +fi +rm -f /tmp/slab-ambient-active + +py="$SLAB_HOME/venv/bin/python3" +helper="$SLAB_BIN/claude-help.py" +if [[ -x "$py" && -f "$helper" ]]; then + "$py" "$helper" 2>>"$LOG" & +fi + +exit 0 diff --git a/slab/bin/claude-prompt-log.sh b/slab/bin/claude-prompt-log.sh index 2e6f8ff7e9..97647dd73e 100755 --- a/slab/bin/claude-prompt-log.sh +++ b/slab/bin/claude-prompt-log.sh @@ -11,6 +11,10 @@ input=$(cat) pkill -f claude-ping-repeat.sh 2>/dev/null pkill -f claude-sleep-schedule.sh 2>/dev/null +# User responded to a pending Notification (or started fresh work) — clear +# the ambient pause flag so the daemon can re-arm ambient on its next tick. +rm -f /tmp/slab-ambient-paused + # keep the machine awake while this new prompt runs "$SLAB_BIN/claude-sleep" awake >/dev/null 2>&1 & diff --git a/slab/bin/claude-stop.sh b/slab/bin/claude-stop.sh index b273146346..f8796cd400 100755 --- a/slab/bin/claude-stop.sh +++ b/slab/bin/claude-stop.sh @@ -43,7 +43,7 @@ stop_ambient() { else pkill -TERM -f lid-reactive.py 2>/dev/null fi - rm -f /tmp/slab-ambient-active + rm -f /tmp/slab-ambient-active /tmp/slab-ambient-paused pkill -x afplay 2>/dev/null } diff --git a/slab/bin/lid-ambient.sh b/slab/bin/lid-ambient.sh index 9a673682ef..5561c53f0d 100755 --- a/slab/bin/lid-ambient.sh +++ b/slab/bin/lid-ambient.sh @@ -4,11 +4,14 @@ # mic-reactive noise, and pluck-arp triggers — and runs only when: # lid is closed AND sleep is disabled AND at least one active marker exists # in $SLAB_HOME/state/active-prompts/ (UserPromptSubmit → Stop lifecycle) -# or $SLAB_HOME/state/active-subagents/ (Task tool → SubagentStop lifecycle). +# or $SLAB_HOME/state/active-subagents/ (Task tool → SubagentStop lifecycle) +# AND the ambient pause flag is absent (Notification → next prompt/Stop). # Lid close always turns off the display (when sleep is disabled). # Lid open asks the listener to fade out (SIGTERM), then lets the return # chime play while it ramps to silence and exits. # Completion + auto-sleep is handled by the Stop hook — see claude-stop.sh. +# Pause-for-feedback ("help me") is handled by the Notification hook — see +# claude-notify.sh. set -u SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} @@ -20,6 +23,10 @@ return_wav="$SOUNDS/lid-return.wav" open_ding="$SOUNDS/lid-open-ding.wav" return_dur=2.5 AMBIENT_FLAG=/tmp/slab-ambient-active +# Set by claude-notify.sh when the agent pauses for user feedback. While +# present, ambient stays off even though an active marker exists. Cleared by +# claude-prompt-log.sh (user responded) or claude-stop.sh (work ended). +AMBIENT_PAUSE_FLAG=/tmp/slab-ambient-paused ACTIVE_DIR="$SLAB_HOME/state/active-prompts" SUBAGENT_DIR="$SLAB_HOME/state/active-subagents" @@ -180,9 +187,10 @@ while true; do fi fi - # Ambient gate: lid closed + sleep disabled + active prompt or subagent. + # Ambient gate: lid closed + sleep disabled + active prompt or subagent, + # and NOT paused-for-feedback by a Notification hook. ambient_wanted=0 - if [[ "$lid_state" == "Yes" && "$sleep_disabled" == "1" ]] && (( active_count > 0 )); then + if [[ "$lid_state" == "Yes" && "$sleep_disabled" == "1" ]] && (( active_count > 0 )) && [[ ! -f "$AMBIENT_PAUSE_FLAG" ]]; then ambient_wanted=1 fi diff --git a/slab/settings-fragment.json b/slab/settings-fragment.json index b75716c6b1..6101175437 100644 --- a/slab/settings-fragment.json +++ b/slab/settings-fragment.json @@ -29,6 +29,13 @@ ] } ], + "Notification": [ + { + "hooks": [ + { "type": "command", "command": "@HOME@/.local/bin/claude-notify.sh", "async": true } + ] + } + ], "SessionStart": [ { "hooks": [ -- 2.51.2