Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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) <noreply@anthropic.com>

+148 -4
+74
slab/bin/claude-help.py
··· 1 + #!/usr/bin/env python3 2 + """Notification stinger: speak "help me" with a short cosine fade-out. 3 + 4 + Invoked from claude-notify.sh when Claude Code emits a Notification hook — 5 + i.e. the agent has paused and is waiting on user feedback (permission prompt 6 + or idle input). Unlike claude-tired.py, this is an attention cue, not a 7 + lullaby: the delivery is brisker and the tail silence is small. Ambient has 8 + already been asked to fade via SIGTERM by the caller, so this phrase plays 9 + over the dissolving bed. 10 + """ 11 + 12 + import os 13 + import subprocess 14 + import sys 15 + import tempfile 16 + import wave 17 + 18 + import numpy as np 19 + import sounddevice as sd 20 + 21 + TEXT = "help me" 22 + RATE = 175 # brisker than the sleep stinger — it's a request, not a sigh 23 + HOLD_FRAC = 0.55 # hold more of the phrase at full volume before tapering 24 + TAIL_SILENCE_S = 0.2 # short pad; we're not about to sleep the machine 25 + 26 + 27 + def synth(): 28 + with tempfile.TemporaryDirectory() as tmp: 29 + aiff = os.path.join(tmp, 'help.aiff') 30 + wav = os.path.join(tmp, 'help.wav') 31 + subprocess.run( 32 + ['/usr/bin/say', '-r', str(RATE), '-o', aiff, TEXT], 33 + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, 34 + ) 35 + subprocess.run( 36 + ['/usr/bin/afconvert', '-f', 'WAVE', '-d', 'LEI16@22050', aiff, wav], 37 + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, 38 + ) 39 + with wave.open(wav, 'rb') as wf: 40 + sr = wf.getframerate() 41 + nch = wf.getnchannels() 42 + raw = wf.readframes(wf.getnframes()) 43 + audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 44 + if nch > 1: 45 + audio = audio.reshape(-1, nch).mean(axis=1) 46 + return audio, sr 47 + 48 + 49 + def envelope(audio, sr): 50 + n = audio.size 51 + if n == 0: 52 + return audio 53 + hold = int(n * HOLD_FRAC) 54 + env = np.ones(n, dtype=np.float32) 55 + fade_n = n - hold 56 + if fade_n > 0: 57 + t = np.linspace(0.0, 1.0, fade_n, dtype=np.float32) 58 + env[hold:] = np.cos(t * np.pi * 0.5) ** 2 59 + tail = np.zeros(int(sr * TAIL_SILENCE_S), dtype=np.float32) 60 + return np.concatenate([audio * env, tail]) 61 + 62 + 63 + def main(): 64 + try: 65 + audio, sr = synth() 66 + except (subprocess.CalledProcessError, FileNotFoundError) as e: 67 + print(f"claude-help: tts failed: {e}", file=sys.stderr) 68 + return 1 69 + sd.play(envelope(audio, sr), sr, blocking=True) 70 + return 0 71 + 72 + 73 + if __name__ == '__main__': 74 + sys.exit(main())
+51
slab/bin/claude-notify.sh
··· 1 + #!/bin/bash 2 + # Claude Notification hook. Fires when the agent pauses and needs user 3 + # feedback (permission prompt or idle-waiting-on-input). We: 4 + # 1. Fade the ambient bed (SIGTERM the reactive listener + synth). 5 + # 2. Drop a pause flag so the lid-ambient daemon's polling loop does NOT 6 + # auto-restart ambient while we wait on the user — the active marker 7 + # for the prompt is still present, so without this flag the daemon 8 + # would re-arm on the next 0.5s tick. 9 + # 3. Speak "help me" with a short fade tail (claude-help.py). 10 + # The pause flag is cleared by the next UserPromptSubmit (user responded) 11 + # or by Stop (work ended). Does NOT sleep the Mac. 12 + set -u 13 + SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} 14 + SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} 15 + LOG=${CLAUDE_NOTIFY_LOG:-$SLAB_HOME/logs/claude-notify.log} 16 + PAUSE_FLAG=/tmp/slab-ambient-paused 17 + mkdir -p "$(dirname "$LOG")" 18 + 19 + input=$(cat) 20 + session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null) 21 + message=$(echo "$input" | jq -r '.message // empty' 2>/dev/null) 22 + echo "$(date '+%Y-%m-%d %H:%M:%S') Notification: session=${session_id:-?} msg=${message:-?}" >> "$LOG" 23 + 24 + # Mark paused before fading so the daemon sees the flag on its next tick. 25 + : > "$PAUSE_FLAG" 26 + 27 + # Fade ambient (mirrors stop_ambient in claude-stop.sh, minus the afplay kill — 28 + # short chimes like the start stinger can finish naturally). 29 + if [[ -f /tmp/lidreactive.pid ]]; then 30 + pid=$(cat /tmp/lidreactive.pid 2>/dev/null) 31 + [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null 32 + rm -f /tmp/lidreactive.pid 33 + else 34 + pkill -TERM -f lid-reactive.py 2>/dev/null 35 + fi 36 + if [[ -f /tmp/lidsynth.pid ]]; then 37 + pid=$(cat /tmp/lidsynth.pid 2>/dev/null) 38 + [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null 39 + rm -f /tmp/lidsynth.pid 40 + else 41 + pkill -TERM -f lid-ambient-synth 2>/dev/null 42 + fi 43 + rm -f /tmp/slab-ambient-active 44 + 45 + py="$SLAB_HOME/venv/bin/python3" 46 + helper="$SLAB_BIN/claude-help.py" 47 + if [[ -x "$py" && -f "$helper" ]]; then 48 + "$py" "$helper" 2>>"$LOG" & 49 + fi 50 + 51 + exit 0
+4
slab/bin/claude-prompt-log.sh
··· 11 11 pkill -f claude-ping-repeat.sh 2>/dev/null 12 12 pkill -f claude-sleep-schedule.sh 2>/dev/null 13 13 14 + # User responded to a pending Notification (or started fresh work) — clear 15 + # the ambient pause flag so the daemon can re-arm ambient on its next tick. 16 + rm -f /tmp/slab-ambient-paused 17 + 14 18 # keep the machine awake while this new prompt runs 15 19 "$SLAB_BIN/claude-sleep" awake >/dev/null 2>&1 & 16 20
+1 -1
slab/bin/claude-stop.sh
··· 43 43 else 44 44 pkill -TERM -f lid-reactive.py 2>/dev/null 45 45 fi 46 - rm -f /tmp/slab-ambient-active 46 + rm -f /tmp/slab-ambient-active /tmp/slab-ambient-paused 47 47 pkill -x afplay 2>/dev/null 48 48 } 49 49
+11 -3
slab/bin/lid-ambient.sh
··· 4 4 # mic-reactive noise, and pluck-arp triggers — and runs only when: 5 5 # lid is closed AND sleep is disabled AND at least one active marker exists 6 6 # in $SLAB_HOME/state/active-prompts/ (UserPromptSubmit → Stop lifecycle) 7 - # or $SLAB_HOME/state/active-subagents/ (Task tool → SubagentStop lifecycle). 7 + # or $SLAB_HOME/state/active-subagents/ (Task tool → SubagentStop lifecycle) 8 + # AND the ambient pause flag is absent (Notification → next prompt/Stop). 8 9 # Lid close always turns off the display (when sleep is disabled). 9 10 # Lid open asks the listener to fade out (SIGTERM), then lets the return 10 11 # chime play while it ramps to silence and exits. 11 12 # Completion + auto-sleep is handled by the Stop hook — see claude-stop.sh. 13 + # Pause-for-feedback ("help me") is handled by the Notification hook — see 14 + # claude-notify.sh. 12 15 set -u 13 16 SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} 14 17 SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} ··· 20 23 open_ding="$SOUNDS/lid-open-ding.wav" 21 24 return_dur=2.5 22 25 AMBIENT_FLAG=/tmp/slab-ambient-active 26 + # Set by claude-notify.sh when the agent pauses for user feedback. While 27 + # present, ambient stays off even though an active marker exists. Cleared by 28 + # claude-prompt-log.sh (user responded) or claude-stop.sh (work ended). 29 + AMBIENT_PAUSE_FLAG=/tmp/slab-ambient-paused 23 30 24 31 ACTIVE_DIR="$SLAB_HOME/state/active-prompts" 25 32 SUBAGENT_DIR="$SLAB_HOME/state/active-subagents" ··· 180 187 fi 181 188 fi 182 189 183 - # Ambient gate: lid closed + sleep disabled + active prompt or subagent. 190 + # Ambient gate: lid closed + sleep disabled + active prompt or subagent, 191 + # and NOT paused-for-feedback by a Notification hook. 184 192 ambient_wanted=0 185 - if [[ "$lid_state" == "Yes" && "$sleep_disabled" == "1" ]] && (( active_count > 0 )); then 193 + if [[ "$lid_state" == "Yes" && "$sleep_disabled" == "1" ]] && (( active_count > 0 )) && [[ ! -f "$AMBIENT_PAUSE_FLAG" ]]; then 186 194 ambient_wanted=1 187 195 fi 188 196
+7
slab/settings-fragment.json
··· 29 29 ] 30 30 } 31 31 ], 32 + "Notification": [ 33 + { 34 + "hooks": [ 35 + { "type": "command", "command": "@HOME@/.local/bin/claude-notify.sh", "async": true } 36 + ] 37 + } 38 + ], 32 39 "SessionStart": [ 33 40 { 34 41 "hooks": [