Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab: lid-closed ambient + Claude Code audio system

New top-level package that wires lid state, mic input, and Claude Code
lifecycle events into one ambient audio experience, plus install scripts
for deployment to fresh MacBooks.

- Lid-closed ambient WAV loop with start / open / stinger chimes
- Reactive listener: mic transients in 2–8kHz band → short pentatonic
pluck-arps that mirror the input's pitch contour; per-session WAV dump
and JSONL event log to ~/.local/share/slab/sessions/
- Claude Stop hook: per-remaining-thread ascending beep ladder
(C6→E7); 'all-done' chime when this was the last session
- Auto-sleep after 2 min idle with dreamy descending sleep-tone
- claude-sleep awake/auto/now/status toggle via passwordless pmset
- Resource monitor sampling CPU/RSS of the stack every 15s
- Portable $HOME/$SLAB_HOME env scripts, launchd KeepAlive daemon
- install.sh symlinks bin/ into ~/.local/bin, builds venv, merges
Claude hooks, installs sudoers rule; uninstall.sh reverses it all

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+1075
+116
slab/README.md
··· 1 + # slab 2 + 3 + A lid-closed ambient + reactive audio system for the MacBook, plus Claude Code completion sounds, prompt logging, and auto-sleep. 4 + 5 + When the lid closes (with sleep suppressed) the laptop becomes a "sleeping slab": an ambient C-major-pentatonic bed loops through the speakers while a mic-listener picks up claps, snaps, shushes, kisses, hums, and close-up sings — responding in real time with short pentatonic pluck-arpeggios that mirror each sound's pitch contour. When Claude Code finishes a prompt, it beeps once per remaining active thread; when all threads are done, it chimes an "all-done" arp, then — if nobody's returned after 2 minutes — plays a dreamy descending sleep-tone and suspends the Mac. 6 + 7 + ## What it does 8 + 9 + | Event | Sound | 10 + |---|---| 11 + | Lid close (`disablesleep=1`) | ascending C-arp + ambient loop starts + display sleep | 12 + | Mic transient while closed (> 2 kHz) | 4-note pentatonic pluck arp, contour mirrors input | 13 + | Lid open | descending ding + pitch-up stinger + ambient stops | 14 + | Claude Stop, **other threads remaining** | N ascending beeps (C6 D6 E6 G6 A6 C7 D7 E7) | 15 + | Claude Stop, all done | "all-done" chime. If lid closed → repeat pings every 30s + 2-min sleep timer | 16 + | 2 min after Stop with lid still closed | sleep tone + `pmset sleepnow` | 17 + | User submits new prompt | cancels pings + sleep timer, sets `disablesleep=1` | 18 + 19 + ## Install 20 + 21 + ```sh 22 + git clone git@github.com:whistlegraph/aesthetic-computer.git 23 + cd aesthetic-computer/slab 24 + ./install.sh 25 + ``` 26 + 27 + The installer will: 28 + 29 + 1. Symlink scripts into `~/.local/bin/` 30 + 2. Copy sound assets into `~/.local/share/slab/sounds/` 31 + 3. Build a Python venv at `~/.local/share/slab/venv/` with `numpy` + `sounddevice` 32 + 4. Install the launchd agent `~/Library/LaunchAgents/computer.slab.daemon.plist` 33 + 5. Merge hooks into `~/.claude/settings.json` (Stop, SubagentStop, UserPromptSubmit, SessionStart) 34 + 6. Install a passwordless-sudo rule at `/etc/sudoers.d/slab-pmset` for `pmset` (prompts for password once) 35 + 36 + Opt-outs: `--no-hooks`, `--no-sudoers`. 37 + 38 + ## Usage 39 + 40 + ```sh 41 + claude-sleep awake # disable all sleep (stay awake with lid closed) 42 + claude-sleep auto # restore normal sleep behavior 43 + claude-sleep now # sleep the Mac immediately 44 + claude-sleep status # show SleepDisabled state 45 + ``` 46 + 47 + Before using lid-closed ambient mode: `claude-sleep awake`. The first time the mic listener runs you'll get a macOS microphone permission prompt. 48 + 49 + ## Uninstall 50 + 51 + ```sh 52 + ./uninstall.sh # unload agent, remove symlinks + hooks + sudoers 53 + ./uninstall.sh --purge # also delete ~/.local/share/slab (sessions, logs, venv, sounds) 54 + ``` 55 + 56 + ## Layout 57 + 58 + ``` 59 + slab/ 60 + ├── bin/ # scripts (symlinked into ~/.local/bin/) 61 + │ ├── lid-ambient.sh # launchd daemon, polls lid state 62 + │ ├── lid-reactive.py # mic → pluck-arp synth (Python) 63 + │ ├── lid-ambient-generate.py # generate ambient.wav 64 + │ ├── claude-sleep # sleep-state toggle 65 + │ ├── claude-stop.sh # Stop-hook entry 66 + │ ├── claude-ping-repeat.sh # repeating 'done' pings 67 + │ ├── claude-sleep-schedule.sh # delayed auto-sleep 68 + │ ├── claude-prompt-log.sh # UserPromptSubmit hook 69 + │ └── slab-monitor.sh # resource sampler 70 + ├── sounds/ # WAV assets (lid chimes, pings, beeps) 71 + ├── launchd/ 72 + │ └── computer.slab.daemon.plist.template 73 + ├── sudoers.d/ 74 + │ └── slab-pmset.template 75 + ├── settings-fragment.json # Claude Code hooks, merged on install 76 + ├── install.sh 77 + └── uninstall.sh 78 + ``` 79 + 80 + Runtime state lives under `~/.local/share/slab/`: 81 + 82 + ``` 83 + sessions/<stamp>.wav per-lid-close recording of Python-generated output 84 + sessions/<stamp>.jsonl trigger events for that session 85 + logs/lidalive.log daemon transitions 86 + logs/reactive.log reactive listener triggers 87 + logs/resources.jsonl CPU/RSS samples every 15s 88 + logs/claude-stop.log Stop-hook activity 89 + venv/ Python venv for the reactive listener 90 + sounds/ installed sound assets (+ regenerated ambient.wav) 91 + ``` 92 + 93 + ## Tuning 94 + 95 + Most knobs live at the top of each script: 96 + 97 + - **Ambient** — `bin/lid-ambient-generate.py`: scale, note-gap range, duration/fade ranges, detuning, drone frequency. Run `python3 bin/lid-ambient-generate.py [seed]` to regenerate a variation. 98 + - **Reactive listener** — `bin/lid-reactive.py`: `HIGH_BAND`, `TRIGGER_RATIO`, `MIN_GAP`, `DIV_FACTOR`, `NOTE_DUR`, `PLUCK_TAIL`, `ARP_NOTES`, `ARP_AMP`, `PENT_MIDI`. 99 + - **Claude ping interval** — `bin/claude-ping-repeat.sh`: `INTERVAL` (default 30s). 100 + - **Auto-sleep delay** — `bin/claude-stop.sh`: the `120` argument to `claude-sleep-schedule.sh`. 101 + - **Resource poll** — `bin/slab-monitor.sh`: `INTERVAL` (default 15s). 102 + 103 + Edits to files in `slab/bin/` take effect immediately because the installed copies are symlinks back into the repo. 104 + 105 + ## Requirements 106 + 107 + - macOS (Apple Silicon tested). Intel should work but the display-sleep-on-lid-close path depends on `pmset displaysleepnow`. 108 + - Homebrew, Python 3.11+, `jq`. 109 + - Microphone permission for the reactive listener (prompted on first run). 110 + 111 + ## Notes 112 + 113 + - `pmset disablesleep 1` suppresses **all** sleep, including explicit `pmset sleepnow` — the sudoers rule lets the scheduler toggle it back off before suspending. 114 + - The reactive listener writes 44.1 kHz mono int16 WAVs of only its own output (the pluck arps). The looping ambient `afplay` and the chimes aren't captured — that would need a loopback audio device like BlackHole. 115 + - Session files accumulate forever by default. Purge periodically: `rm ~/.local/share/slab/sessions/*`. 116 + - Apple Silicon MacBooks have no accelerometer, gyroscope, or accessible CPU temp via public APIs — so shake/gesture and temperature-modulation paths aren't implemented here yet.
+24
slab/bin/claude-ping-repeat.sh
··· 1 + #!/bin/bash 2 + # Random high-pitched major-chord ping every INTERVAL seconds while lid is CLOSED. 3 + # Exits automatically once the lid is reopened. 4 + set -u 5 + SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} 6 + 7 + INTERVAL=${INTERVAL:-30} 8 + CHORDS=(C F G D A Eb Bb Ab) 9 + DIR="$SLAB_HOME/sounds" 10 + 11 + lid_closed() { 12 + local s 13 + s=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') 14 + [[ "$s" == "Yes" ]] 15 + } 16 + 17 + while true; do 18 + sleep "$INTERVAL" 19 + if ! lid_closed; then 20 + exit 0 21 + fi 22 + chord=${CHORDS[$((RANDOM % ${#CHORDS[@]}))]} 23 + /usr/bin/afplay "$DIR/ping_${chord}.wav" 2>/dev/null 24 + done
+22
slab/bin/claude-prompt-log.sh
··· 1 + #!/bin/bash 2 + # UserPromptSubmit hook: log prompt, cancel pending pings + sleep, set awake. 3 + set -u 4 + SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} 5 + PROMPT_LOG=${PROMPT_LOG:-$HOME/.claude/prompts.log.jsonl} 6 + 7 + input=$(cat) 8 + 9 + pkill -f claude-ping-repeat.sh 2>/dev/null 10 + pkill -f claude-sleep-schedule.sh 2>/dev/null 11 + 12 + # keep the machine awake while this new prompt runs 13 + "$SLAB_BIN/claude-sleep" awake >/dev/null 2>&1 & 14 + 15 + if [[ -n "$input" ]]; then 16 + mkdir -p "$(dirname "$PROMPT_LOG")" 17 + echo "$input" | jq -c --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ 18 + '{ts: $ts, session: .session_id, cwd: .cwd, prompt: .prompt}' \ 19 + >> "$PROMPT_LOG" 2>/dev/null 20 + fi 21 + 22 + exit 0
+34
slab/bin/claude-sleep
··· 1 + #!/bin/bash 2 + # Unified sleep control for the slab workflow. 3 + # 4 + # claude-sleep awake # disable all sleep (stay awake with lid closed) 5 + # claude-sleep auto # restore normal sleep behavior 6 + # claude-sleep now # sleep the Mac immediately 7 + # claude-sleep status # show SleepDisabled state 8 + # 9 + # Requires passwordless sudo for /usr/bin/pmset. See slab install.sh. 10 + 11 + set -u 12 + PMSET=/usr/bin/pmset 13 + 14 + case "${1:-status}" in 15 + awake) 16 + sudo -n "$PMSET" -a disablesleep 1 2>/dev/null && echo "awake (SleepDisabled=1)" 17 + ;; 18 + auto) 19 + sudo -n "$PMSET" -a disablesleep 0 2>/dev/null && echo "auto (SleepDisabled=0)" 20 + ;; 21 + now) 22 + sudo -n "$PMSET" -a disablesleep 0 2>/dev/null 23 + sudo -n "$PMSET" sleepnow 2>/dev/null \ 24 + || osascript -e 'tell application "System Events" to sleep' 2>/dev/null 25 + ;; 26 + status) 27 + state=$("$PMSET" -g | awk '/SleepDisabled/{print $2; exit}') 28 + echo "SleepDisabled=${state:-0}" 29 + ;; 30 + *) 31 + echo "usage: $0 [awake|auto|now|status]" >&2 32 + exit 1 33 + ;; 34 + esac
+17
slab/bin/claude-sleep-schedule.sh
··· 1 + #!/bin/bash 2 + # Sleeps the Mac after DELAY seconds if the lid is still closed at that point. 3 + # Plays the sleep tone first for a dreamy auditory cue. 4 + set -u 5 + SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} 6 + SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} 7 + 8 + DELAY=${1:-120} 9 + SLEEP_TONE="$SLAB_HOME/sounds/sleep-tone.wav" 10 + 11 + sleep "$DELAY" 12 + 13 + lid=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') 14 + if [[ "$lid" == "Yes" ]]; then 15 + /usr/bin/afplay "$SLEEP_TONE" 2>/dev/null 16 + "$SLAB_BIN/claude-sleep" now 17 + fi
+40
slab/bin/claude-stop.sh
··· 1 + #!/bin/bash 2 + # Claude Stop hook. 3 + # other > 0 → N distinct ascending pentatonic beeps (capped at 8). 4 + # other = 0 → "all done" rising arp. If lid is closed, start repeat + auto-sleep. 5 + set -u 6 + SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} 7 + SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} 8 + CH="$SLAB_HOME/sounds" 9 + LOG=${CLAUDE_STOP_LOG:-$SLAB_HOME/logs/claude-stop.log} 10 + mkdir -p "$(dirname "$LOG")" 11 + 12 + pkill -f claude-ping-repeat.sh 2>/dev/null 13 + pkill -f claude-sleep-schedule.sh 2>/dev/null 14 + 15 + # count running Claude Code sessions (inner `claude` CLI; anchor to /Users/) 16 + total=$(ps -eo command | grep -cE '^/Users/.*/claude\.app/Contents/MacOS/claude ') 17 + other=$((total - 1)) 18 + [[ $other -lt 0 ]] && other=0 19 + 20 + echo "$(date '+%Y-%m-%d %H:%M:%S') Stop: total=$total other=$other" >> "$LOG" 21 + 22 + if [[ $other -eq 0 ]]; then 23 + /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null & 24 + else 25 + max=8 26 + n=$other 27 + [[ $n -gt $max ]] && n=$max 28 + for ((i=1; i<=n; i++)); do 29 + /usr/bin/afplay "$CH/beep_${i}.wav" 2>/dev/null 30 + sleep 0.08 31 + done 32 + fi 33 + 34 + lid=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') 35 + if [[ "$lid" == "Yes" && $other -eq 0 ]]; then 36 + nohup "$SLAB_BIN/claude-ping-repeat.sh" > /dev/null 2>&1 & 37 + nohup "$SLAB_BIN/claude-sleep-schedule.sh" 120 > /dev/null 2>&1 & 38 + fi 39 + 40 + exit 0
+104
slab/bin/lid-ambient-generate.py
··· 1 + #!/usr/bin/env python3 2 + """Generate the ambient WAV used as the base layer of the lid-closed soundscape. 3 + 4 + Tweak knobs at top; re-run to regenerate. 5 + Usage: lid-ambient-generate.py [seed] 6 + """ 7 + import math 8 + import wave 9 + import random 10 + import array 11 + import os 12 + import sys 13 + import time 14 + 15 + # --- knobs --- 16 + TOTAL_SECONDS = 300.0 17 + NOTE_GAP_RANGE = (4.0, 10.0) 18 + NOTE_DUR_RANGE = (18.0, 45.0) 19 + FADE_IN_RANGE = (3.5, 8.0) 20 + FADE_OUT_RANGE = (8.0, 18.0) 21 + AMP_RANGE = (0.07, 0.13) 22 + DETUNE_CENTS = 7.0 23 + DRONE_EVERY = (30.0, 60.0) 24 + 25 + # C major pentatonic, C3..E5 26 + SCALE_MIDI = [48, 50, 52, 55, 57, 60, 62, 64, 67, 69, 72, 74, 76] 27 + 28 + SLAB_HOME = os.environ.get('SLAB_HOME', os.path.expanduser('~/.local/share/slab')) 29 + OUT = os.path.join(SLAB_HOME, 'sounds', 'ambient.wav') 30 + SR = 44100 31 + 32 + 33 + def main(): 34 + seed = int(sys.argv[1]) if len(sys.argv) > 1 else random.randint(0, 99999) 35 + random.seed(seed) 36 + t0 = time.time() 37 + 38 + N = int(SR * TOTAL_SECONDS) 39 + pitches = [440.0 * 2**((m - 69) / 12) for m in SCALE_MIDI] 40 + buf = array.array('d', [0.0]) * N 41 + 42 + def add_note(freq, start_t, dur, amp, fin, fout, det_c=0.0): 43 + si = int(SR * start_t) 44 + ns = int(SR * dur) 45 + w1 = 2 * math.pi * freq 46 + w2 = 2 * math.pi * freq * 2**(det_c / 1200) 47 + for i in range(ns): 48 + idx = si + i 49 + if idx >= N: break 50 + lt = i / SR 51 + if lt < fin: 52 + env = lt / fin 53 + elif lt > dur - fout: 54 + env = max(0.0, (dur - lt) / fout) 55 + else: 56 + env = 1.0 57 + buf[idx] += amp * env * (0.6 * math.sin(w1 * lt) + 0.4 * math.sin(w2 * lt)) 58 + 59 + count = 0 60 + t = 0.0 61 + while t < TOTAL_SECONDS - 10: 62 + add_note( 63 + random.choice(pitches), t, 64 + random.uniform(*NOTE_DUR_RANGE), 65 + random.uniform(*AMP_RANGE), 66 + random.uniform(*FADE_IN_RANGE), 67 + random.uniform(*FADE_OUT_RANGE), 68 + random.uniform(-DETUNE_CENTS, DETUNE_CENTS), 69 + ) 70 + count += 1 71 + t += random.uniform(*NOTE_GAP_RANGE) 72 + 73 + td = 0.0 74 + while td < TOTAL_SECONDS - 30: 75 + add_note( 76 + random.choice(pitches[:4]) / 2.0, td, 77 + random.uniform(40, 80), 78 + random.uniform(0.05, 0.09), 79 + random.uniform(6, 12), 80 + random.uniform(15, 25), 81 + random.uniform(-5, 5), 82 + ) 83 + td += random.uniform(*DRONE_EVERY) 84 + 85 + peak = max(abs(s) for s in buf) or 1.0 86 + scale = (0.85 / peak) * 32767 87 + out = array.array('h', [0]) * N 88 + for i in range(N): 89 + v = int(buf[i] * scale) 90 + if v > 32767: v = 32767 91 + elif v < -32768: v = -32768 92 + out[i] = v 93 + 94 + os.makedirs(os.path.dirname(OUT), exist_ok=True) 95 + with wave.open(OUT, 'wb') as w: 96 + w.setnchannels(1); w.setsampwidth(2); w.setframerate(SR) 97 + w.writeframes(out.tobytes()) 98 + 99 + print(f'wrote {OUT}') 100 + print(f'seed={seed} notes={count} peak={peak:.3f} elapsed={time.time()-t0:.1f}s') 101 + 102 + 103 + if __name__ == '__main__': 104 + main()
+133
slab/bin/lid-ambient.sh
··· 1 + #!/bin/bash 2 + # lid-ambient daemon. Long-lived via launchd KeepAlive; polls lid every POLL 3 + # seconds; on transition plays chimes and manages the ambient loop + reactive 4 + # listener + resource monitor. Turns off display on lid close. 5 + set -u 6 + SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} 7 + SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} 8 + 9 + POLL=${POLL:-0.5} 10 + SOUNDS="$SLAB_HOME/sounds" 11 + wav="$SOUNDS/ambient.wav" 12 + start_wav="$SOUNDS/lid-start.wav" 13 + return_wav="$SOUNDS/lid-return.wav" 14 + open_ding="$SOUNDS/lid-open-ding.wav" 15 + return_dur=1.15 16 + 17 + PID_DIR=/tmp 18 + pid_file="$PID_DIR/lidambient.pid" 19 + reactive_pid_file="$PID_DIR/lidreactive.pid" 20 + monitor_pid_file="$PID_DIR/slab-monitor.pid" 21 + log="$SLAB_HOME/logs/lidalive.log" 22 + mkdir -p "$(dirname "$log")" 23 + 24 + reactive_py="$SLAB_HOME/venv/bin/python3" 25 + reactive_script="$SLAB_BIN/lid-reactive.py" 26 + 27 + log_msg() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" >> "$log"; } 28 + 29 + stop_player() { 30 + local pid 31 + if [[ -f "$pid_file" ]]; then 32 + pid=$(cat "$pid_file" 2>/dev/null) 33 + if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then 34 + pkill -P "$pid" 2>/dev/null 35 + kill "$pid" 2>/dev/null 36 + fi 37 + rm -f "$pid_file" 38 + fi 39 + pkill -x afplay 2>/dev/null 40 + } 41 + 42 + start_player() { 43 + (while true; do /usr/bin/afplay "$wav"; done) > /dev/null 2>&1 & 44 + echo $! > "$pid_file" 45 + log_msg "started ambient loop pid $!" 46 + } 47 + 48 + start_reactive() { 49 + if [[ -x "$reactive_py" && -f "$reactive_script" ]]; then 50 + nohup "$reactive_py" "$reactive_script" > /dev/null 2>&1 & 51 + echo $! > "$reactive_pid_file" 52 + log_msg "started reactive listener pid $!" 53 + fi 54 + } 55 + 56 + stop_reactive() { 57 + local pid 58 + if [[ -f "$reactive_pid_file" ]]; then 59 + pid=$(cat "$reactive_pid_file" 2>/dev/null) 60 + [[ -n "$pid" ]] && kill "$pid" 2>/dev/null 61 + rm -f "$reactive_pid_file" 62 + fi 63 + pkill -f lid-reactive.py 2>/dev/null 64 + } 65 + 66 + start_monitor() { 67 + if [[ -x "$SLAB_BIN/slab-monitor.sh" ]]; then 68 + nohup "$SLAB_BIN/slab-monitor.sh" 15 > /dev/null 2>&1 & 69 + echo $! > "$monitor_pid_file" 70 + log_msg "started resource monitor pid $!" 71 + fi 72 + } 73 + 74 + stop_monitor() { 75 + local pid 76 + if [[ -f "$monitor_pid_file" ]]; then 77 + pid=$(cat "$monitor_pid_file" 2>/dev/null) 78 + [[ -n "$pid" ]] && kill "$pid" 2>/dev/null 79 + rm -f "$monitor_pid_file" 80 + fi 81 + pkill -f slab-monitor.sh 2>/dev/null 82 + } 83 + 84 + cleanup() { 85 + log_msg "daemon exiting, cleaning up" 86 + stop_player 87 + stop_reactive 88 + stop_monitor 89 + exit 0 90 + } 91 + trap cleanup SIGTERM SIGINT SIGHUP 92 + 93 + log_msg "daemon starting (pid $$)" 94 + stop_player 95 + stop_reactive 96 + stop_monitor 97 + start_monitor 98 + 99 + prev="" 100 + while true; do 101 + state=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') 102 + sleep_disabled=$(pmset -g | awk '/SleepDisabled/{print $2; exit}') 103 + sleep_disabled=${sleep_disabled:-0} 104 + 105 + # No -> Yes (lid just closed) 106 + if [[ "$state" == "Yes" && "$prev" == "No" ]]; then 107 + log_msg "lid CLOSED (sleep_disabled=$sleep_disabled)" 108 + if [[ "$sleep_disabled" == "1" ]]; then 109 + /usr/bin/afplay "$start_wav" 2>/dev/null & 110 + start_player 111 + start_reactive 112 + (sleep 0.6; sudo -n /usr/bin/pmset displaysleepnow 2>/dev/null) & 113 + fi 114 + fi 115 + 116 + # Yes -> No (lid just opened): ding + return stinger, kill ambient after stinger 117 + if [[ "$state" == "No" && "$prev" == "Yes" ]]; then 118 + log_msg "lid OPENED - ding + return stinger" 119 + /usr/bin/afplay "$open_ding" 2>/dev/null & 120 + /usr/bin/afplay "$return_wav" 2>/dev/null & 121 + (sleep "$return_dur" 122 + cur=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') 123 + if [[ "$cur" == "No" ]]; then 124 + stop_player 125 + stop_reactive 126 + log_msg "ambient + reactive stopped after return stinger" 127 + fi 128 + ) & 129 + fi 130 + 131 + prev="$state" 132 + sleep "$POLL" 133 + done
+258
slab/bin/lid-reactive.py
··· 1 + #!/usr/bin/env python3 2 + """Slab reactive listener. Detects high-frequency transients (claps/snaps/ 3 + shushes/kisses/hums/sings) and responds with short pentatonic pluck-arpeggios 4 + that MIRROR the input's pitch contour (rising → asc arp, falling → desc). 5 + 6 + Writes per-session artifacts to $SLAB_HOME/sessions/: 7 + - <timestamp>.wav (Python-generated output mix, int16 mono 44.1 kHz) 8 + - <timestamp>.jsonl (trigger events: peak, base, contour, notes, energy) 9 + 10 + Launched by lid-ambient.sh on lid-close; terminated on lid-open. 11 + """ 12 + 13 + import sys 14 + import os 15 + import time 16 + import json 17 + import wave 18 + import signal 19 + import threading 20 + import math 21 + from datetime import datetime 22 + 23 + import numpy as np 24 + import sounddevice as sd 25 + 26 + SLAB_HOME = os.environ.get('SLAB_HOME', os.path.expanduser('~/.local/share/slab')) 27 + LOG_PATH = os.path.join(SLAB_HOME, 'logs', 'reactive.log') 28 + SESSION_DIR = os.path.join(SLAB_HOME, 'sessions') 29 + os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) 30 + os.makedirs(SESSION_DIR, exist_ok=True) 31 + 32 + # -------- config (tuneable) -------- 33 + SR = 44100 34 + BLOCK = 1024 35 + HIGH_BAND = (2000.0, 8000.0) 36 + TRIGGER_RATIO = 3.5 37 + MIN_ABS_ENERGY = 0.02 38 + MIN_GAP = 0.3 39 + FLOOR_DECAY = 0.98 40 + WARMUP = 1.5 41 + 42 + DIV_FACTOR = 5.0 43 + NOTE_DUR = 0.09 44 + PLUCK_TAIL = 0.22 45 + ARP_NOTES = 4 46 + ARP_AMP = 0.22 47 + 48 + # C major pentatonic, C3..C6 49 + PENT_MIDI = [48, 50, 52, 55, 57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84] 50 + PENT_HZ = [440.0 * 2 ** ((m - 69) / 12) for m in PENT_MIDI] 51 + 52 + RECENT_SEC = 0.20 53 + RECENT_N = int(SR * RECENT_SEC) 54 + 55 + _stamp = datetime.now().strftime('%Y%m%d-%H%M%S') 56 + WAV_PATH = os.path.join(SESSION_DIR, f'{_stamp}.wav') 57 + JSONL_PATH = os.path.join(SESSION_DIR, f'{_stamp}.jsonl') 58 + 59 + 60 + def log(msg): 61 + with open(LOG_PATH, 'a') as f: 62 + f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") 63 + 64 + 65 + # -------- session artifacts -------- 66 + _wav = wave.open(WAV_PATH, 'wb') 67 + _wav.setnchannels(1) 68 + _wav.setsampwidth(2) 69 + _wav.setframerate(SR) 70 + _jsonl = open(JSONL_PATH, 'w', buffering=1) 71 + _wav_lock = threading.Lock() 72 + 73 + 74 + def session_event(kind, **kw): 75 + kw['event'] = kind 76 + kw['ts'] = time.time() 77 + try: 78 + _jsonl.write(json.dumps(kw) + '\n') 79 + except Exception: 80 + pass 81 + 82 + 83 + def session_write_frames(float_buf): 84 + pcm = np.clip(float_buf * 32767.0, -32768, 32767).astype(np.int16).tobytes() 85 + with _wav_lock: 86 + try: 87 + _wav.writeframes(pcm) 88 + except Exception: 89 + pass 90 + 91 + 92 + # -------- synthesis -------- 93 + def make_pluck(freq, tail=PLUCK_TAIL, amp=ARP_AMP): 94 + n = int(SR * tail) 95 + t = np.arange(n) / SR 96 + sig = np.sin(2 * np.pi * freq * t) + 0.25 * np.sin(2 * np.pi * freq * 2 * t) 97 + env = np.exp(-9 * t) 98 + atk = 0.003 99 + m_atk = t < atk 100 + env[m_atk] *= t[m_atk] / atk 101 + return (amp * env * sig).astype(np.float32) 102 + 103 + 104 + def make_arp(base_freq, contour, n=ARP_NOTES): 105 + log_b = math.log(base_freq) 106 + idx0 = min(range(len(PENT_HZ)), 107 + key=lambda i: abs(math.log(PENT_HZ[i]) - log_b)) 108 + if contour == 'asc': 109 + offsets = [0, 2, 4, 6] 110 + elif contour == 'desc': 111 + offsets = [6, 4, 2, 0] 112 + else: 113 + offsets = [0, 2, 4, 2] 114 + notes = [] 115 + for off in offsets[:n]: 116 + i = max(0, min(len(PENT_HZ) - 1, idx0 + off)) 117 + notes.append(PENT_HZ[i]) 118 + total = int(SR * (NOTE_DUR * (n - 1) + PLUCK_TAIL)) 119 + buf = np.zeros(total, dtype=np.float32) 120 + for i, f in enumerate(notes): 121 + pluck = make_pluck(f) 122 + start = int(i * NOTE_DUR * SR) 123 + end = min(start + len(pluck), total) 124 + buf[start:end] += pluck[: end - start] 125 + return buf, notes 126 + 127 + 128 + def detect_contour(window): 129 + if len(window) < 256: 130 + return 'flat' 131 + thirds = np.array_split(window, 3) 132 + peaks = [] 133 + for chunk in thirds: 134 + if len(chunk) < 64: 135 + continue 136 + w = chunk * np.hanning(len(chunk)) 137 + spec = np.abs(np.fft.rfft(w)) 138 + freqs = np.fft.rfftfreq(len(chunk), 1.0 / SR) 139 + mask = (freqs >= HIGH_BAND[0]) & (freqs <= HIGH_BAND[1]) 140 + if not mask.any(): 141 + continue 142 + band = spec.copy() 143 + band[~mask] = 0 144 + peaks.append(float(freqs[int(np.argmax(band))])) 145 + if len(peaks) < 2: 146 + return 'flat' 147 + if peaks[-1] > peaks[0] * 1.12: 148 + return 'asc' 149 + if peaks[-1] < peaks[0] * 0.88: 150 + return 'desc' 151 + return 'flat' 152 + 153 + 154 + # -------- state -------- 155 + active_voices = [] 156 + voices_lock = threading.Lock() 157 + floor = 1e-3 158 + last_trigger = 0.0 159 + start_time = time.time() 160 + recent_audio = np.zeros(RECENT_N, dtype=np.float32) 161 + 162 + 163 + def input_callback(indata, frames, time_info, status): 164 + global floor, last_trigger, recent_audio 165 + audio = indata[:, 0].astype(np.float32) 166 + if frames >= RECENT_N: 167 + recent_audio = audio[-RECENT_N:].copy() 168 + else: 169 + recent_audio = np.concatenate([recent_audio[frames:], audio]) 170 + 171 + win = audio * np.hanning(len(audio)) 172 + spec = np.abs(np.fft.rfft(win)) 173 + freqs = np.fft.rfftfreq(len(audio), 1.0 / SR) 174 + mask = (freqs >= HIGH_BAND[0]) & (freqs <= HIGH_BAND[1]) 175 + high_energy = float(np.sum(spec[mask] ** 2)) 176 + floor = FLOOR_DECAY * floor + (1 - FLOOR_DECAY) * high_energy 177 + 178 + now = time.time() 179 + if now - start_time < WARMUP: 180 + return 181 + if (high_energy > TRIGGER_RATIO * floor 182 + and high_energy > MIN_ABS_ENERGY 183 + and now - last_trigger > MIN_GAP): 184 + last_trigger = now 185 + band_spec = spec.copy() 186 + band_spec[~mask] = 0 187 + peak_idx = int(np.argmax(band_spec)) 188 + peak_freq = float(freqs[peak_idx]) 189 + base = peak_freq / DIV_FACTOR 190 + contour = detect_contour(recent_audio) 191 + arp, notes = make_arp(base, contour) 192 + with voices_lock: 193 + active_voices.append([arp, 0]) 194 + log(f"trig peak={peak_freq:7.1f}Hz base={base:6.1f}Hz " 195 + f"contour={contour} arp=[{' '.join(f'{f:.0f}' for f in notes)}]") 196 + session_event('arp', 197 + peak_hz=round(peak_freq, 1), 198 + base_hz=round(base, 1), 199 + contour=contour, 200 + notes=[round(f, 1) for f in notes], 201 + energy=round(high_energy, 4), 202 + floor=round(floor, 4)) 203 + floor = max(floor, high_energy * 0.5) 204 + 205 + 206 + def output_callback(outdata, frames, time_info, status): 207 + outdata.fill(0) 208 + with voices_lock: 209 + remaining = [] 210 + for v in active_voices: 211 + buf, pos = v 212 + take = min(len(buf) - pos, frames) 213 + if take > 0: 214 + outdata[:take, 0] += buf[pos:pos + take] 215 + v[1] = pos + take 216 + if v[1] < len(buf): 217 + remaining.append(v) 218 + active_voices[:] = remaining 219 + np.clip(outdata, -0.95, 0.95, out=outdata) 220 + session_write_frames(outdata[:, 0]) 221 + 222 + 223 + def shutdown(signum, frame): 224 + log(f"shutdown signal {signum}") 225 + session_event('shutdown', signal=int(signum)) 226 + try: 227 + _wav.close() 228 + _jsonl.close() 229 + except Exception: 230 + pass 231 + sys.exit(0) 232 + 233 + 234 + signal.signal(signal.SIGTERM, shutdown) 235 + signal.signal(signal.SIGINT, shutdown) 236 + 237 + 238 + def main(): 239 + log(f"listener starting (arp mode) session={_stamp}") 240 + session_event('listener_start', wav=WAV_PATH, jsonl=JSONL_PATH) 241 + try: 242 + out_stream = sd.OutputStream( 243 + samplerate=SR, channels=1, blocksize=BLOCK, 244 + callback=output_callback, dtype='float32') 245 + in_stream = sd.InputStream( 246 + samplerate=SR, channels=1, blocksize=BLOCK, 247 + callback=input_callback, dtype='float32') 248 + with in_stream, out_stream: 249 + while True: 250 + time.sleep(1) 251 + except Exception as e: 252 + log(f"error: {e!r}") 253 + session_event('error', message=repr(e)) 254 + raise 255 + 256 + 257 + if __name__ == '__main__': 258 + main()
+36
slab/bin/slab-monitor.sh
··· 1 + #!/bin/bash 2 + # Slab resource monitor. Samples CPU/RSS of ambient-system processes every 3 + # INTERVAL seconds and appends a JSONL line per process. Runs as a child of 4 + # the lid-ambient daemon. 5 + set -u 6 + SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} 7 + 8 + INTERVAL=${1:-15} 9 + LOG_DIR="$SLAB_HOME/logs" 10 + mkdir -p "$LOG_DIR" 11 + LOG="$LOG_DIR/resources.jsonl" 12 + 13 + PATTERNS=( 14 + "lid-ambient.sh" 15 + "lid-reactive.py" 16 + "claude-ping-repeat.sh" 17 + "claude-sleep-schedule.sh" 18 + "afplay" 19 + ) 20 + 21 + trap 'exit 0' SIGTERM SIGINT 22 + 23 + while true; do 24 + ts=$(date -u +%Y-%m-%dT%H:%M:%SZ) 25 + for pat in "${PATTERNS[@]}"; do 26 + ps -eo pid=,rss=,pcpu=,command= 2>/dev/null \ 27 + | grep -E "$pat" \ 28 + | grep -v grep \ 29 + | grep -v slab-monitor.sh \ 30 + | while read -r pid rss pcpu cmd; do 31 + printf '{"ts":"%s","pattern":"%s","pid":%s,"rss_kb":%s,"cpu_pct":%s}\n' \ 32 + "$ts" "$pat" "$pid" "$rss" "$pcpu" 33 + done 34 + done >> "$LOG" 35 + sleep "$INTERVAL" 36 + done
+142
slab/install.sh
··· 1 + #!/bin/bash 2 + # slab installer — sets up the lid-ambient / Claude Code audio system on macOS. 3 + # 4 + # Usage: 5 + # ./install.sh # install everything interactively 6 + # ./install.sh --no-hooks # skip Claude Code hook merge 7 + # ./install.sh --no-sudoers # skip passwordless-sudo rule for pmset 8 + # ./install.sh --uninstall # same as ./uninstall.sh 9 + # 10 + # Idempotent: re-running updates symlinks, plist, etc. 11 + 12 + set -euo pipefail 13 + 14 + SLAB_REPO=$(cd "$(dirname "$0")" && pwd) 15 + HOME_DIR=${HOME:-$(echo ~)} 16 + USER_NAME=${USER:-$(whoami)} 17 + 18 + SLAB_HOME="$HOME_DIR/.local/share/slab" 19 + SLAB_BIN="$HOME_DIR/.local/bin" 20 + CLAUDE_DIR="$HOME_DIR/.claude" 21 + LAUNCH_AGENTS="$HOME_DIR/Library/LaunchAgents" 22 + PLIST_NAME=computer.slab.daemon.plist 23 + PLIST_INSTALLED="$LAUNCH_AGENTS/$PLIST_NAME" 24 + SUDOERS_FILE=/etc/sudoers.d/slab-pmset 25 + 26 + DO_HOOKS=1 27 + DO_SUDOERS=1 28 + for arg in "$@"; do 29 + case "$arg" in 30 + --no-hooks) DO_HOOKS=0 ;; 31 + --no-sudoers) DO_SUDOERS=0 ;; 32 + --uninstall) exec "$SLAB_REPO/uninstall.sh" ;; 33 + -h|--help) grep '^# ' "$0" | sed 's/^# //'; exit 0 ;; 34 + esac 35 + done 36 + 37 + say() { printf '\033[1;36m• %s\033[0m\n' "$*"; } 38 + warn() { printf '\033[1;33m⚠ %s\033[0m\n' "$*"; } 39 + err() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; } 40 + 41 + # ------------ prereqs ------------ 42 + say "checking prerequisites" 43 + for cmd in brew python3 jq ioreg pmset afplay osascript; do 44 + if ! command -v "$cmd" >/dev/null 2>&1; then 45 + err "missing: $cmd" 46 + [[ "$cmd" == "brew" ]] && echo " install Homebrew first: https://brew.sh" 47 + [[ "$cmd" == "jq" ]] && echo " brew install jq" 48 + exit 1 49 + fi 50 + done 51 + 52 + # ------------ layout ------------ 53 + say "creating directories" 54 + mkdir -p "$SLAB_HOME/sounds" "$SLAB_HOME/logs" "$SLAB_HOME/sessions" "$SLAB_BIN" "$LAUNCH_AGENTS" 55 + 56 + # ------------ scripts (symlinked from repo) ------------ 57 + say "symlinking scripts into $SLAB_BIN" 58 + for f in "$SLAB_REPO/bin/"*; do 59 + base=$(basename "$f") 60 + dest="$SLAB_BIN/$base" 61 + rm -f "$dest" 62 + ln -s "$f" "$dest" 63 + chmod +x "$f" 64 + done 65 + 66 + # ------------ sounds ------------ 67 + say "copying sounds to $SLAB_HOME/sounds" 68 + cp -f "$SLAB_REPO/sounds/"*.wav "$SLAB_HOME/sounds/" 69 + 70 + # regenerate ambient.wav if missing or flagged 71 + if [[ ! -f "$SLAB_HOME/sounds/ambient.wav" ]]; then 72 + say "generating ambient.wav (~17s)" 73 + SLAB_HOME="$SLAB_HOME" python3 "$SLAB_REPO/bin/lid-ambient-generate.py" || \ 74 + warn "ambient.wav generation failed — rerun manually later" 75 + fi 76 + 77 + # ------------ python venv (numpy + sounddevice for reactive listener) ------------ 78 + if [[ ! -x "$SLAB_HOME/venv/bin/python3" ]]; then 79 + say "creating Python venv at $SLAB_HOME/venv" 80 + python3 -m venv "$SLAB_HOME/venv" 81 + fi 82 + say "installing numpy + sounddevice into venv" 83 + "$SLAB_HOME/venv/bin/pip" install --quiet --upgrade pip 84 + "$SLAB_HOME/venv/bin/pip" install --quiet numpy sounddevice 85 + 86 + # ------------ launchd plist ------------ 87 + say "installing launchd plist → $PLIST_INSTALLED" 88 + sed "s|@HOME@|$HOME_DIR|g" "$SLAB_REPO/launchd/$PLIST_NAME.template" > "$PLIST_INSTALLED" 89 + 90 + # load (or reload) the agent 91 + if launchctl list | grep -q computer.slab.daemon; then 92 + launchctl unload "$PLIST_INSTALLED" 2>/dev/null || true 93 + fi 94 + launchctl load "$PLIST_INSTALLED" 95 + 96 + # ------------ Claude Code hooks ------------ 97 + if [[ $DO_HOOKS -eq 1 ]]; then 98 + say "merging Claude Code hooks into $CLAUDE_DIR/settings.json" 99 + mkdir -p "$CLAUDE_DIR" 100 + target="$CLAUDE_DIR/settings.json" 101 + fragment=$(sed "s|@HOME@|$HOME_DIR|g" "$SLAB_REPO/settings-fragment.json") 102 + if [[ -f "$target" ]]; then 103 + # merge: slab hooks overwrite existing ones under same event keys 104 + tmp=$(mktemp) 105 + echo "$fragment" | jq -s '.[0]' > "$tmp.frag" 106 + jq -s '.[0] * .[1]' "$target" "$tmp.frag" > "$tmp" 107 + mv "$tmp" "$target" 108 + rm -f "$tmp.frag" 109 + else 110 + echo "$fragment" > "$target" 111 + fi 112 + fi 113 + 114 + # ------------ passwordless sudo for pmset ------------ 115 + if [[ $DO_SUDOERS -eq 1 ]]; then 116 + if [[ ! -f "$SUDOERS_FILE" ]]; then 117 + say "installing sudoers rule (requires sudo password once)" 118 + rendered=$(sed "s|@USER@|$USER_NAME|g" "$SLAB_REPO/sudoers.d/slab-pmset.template") 119 + echo "$rendered" | sudo tee "$SUDOERS_FILE" > /dev/null 120 + sudo chmod 440 "$SUDOERS_FILE" 121 + else 122 + say "sudoers rule already present" 123 + fi 124 + fi 125 + 126 + say "install complete" 127 + cat <<EOF 128 + 129 + daemon: $PLIST_INSTALLED 130 + scripts: $SLAB_BIN 131 + sounds + venv: $SLAB_HOME 132 + sessions + logs: $SLAB_HOME/{sessions,logs} 133 + Claude hooks: $CLAUDE_DIR/settings.json$([ $DO_HOOKS -eq 0 ] && echo ' (skipped)') 134 + sudoers: $SUDOERS_FILE$([ $DO_SUDOERS -eq 0 ] && echo ' (skipped)') 135 + 136 + try it: 137 + claude-sleep status # check sleep state 138 + claude-sleep awake # stay awake with lid closed 139 + tail -f $SLAB_HOME/logs/lidalive.log 140 + 141 + uninstall: $SLAB_REPO/uninstall.sh 142 + EOF
+33
slab/launchd/computer.slab.daemon.plist.template
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>Label</key> 6 + <string>computer.slab.daemon</string> 7 + <key>ProgramArguments</key> 8 + <array> 9 + <string>@HOME@/.local/bin/lid-ambient.sh</string> 10 + </array> 11 + <key>EnvironmentVariables</key> 12 + <dict> 13 + <key>HOME</key> 14 + <string>@HOME@</string> 15 + <key>PATH</key> 16 + <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string> 17 + <key>SLAB_HOME</key> 18 + <string>@HOME@/.local/share/slab</string> 19 + <key>SLAB_BIN</key> 20 + <string>@HOME@/.local/bin</string> 21 + </dict> 22 + <key>RunAtLoad</key> 23 + <true/> 24 + <key>KeepAlive</key> 25 + <true/> 26 + <key>ThrottleInterval</key> 27 + <integer>5</integer> 28 + <key>StandardOutPath</key> 29 + <string>/tmp/slab-daemon.out</string> 30 + <key>StandardErrorPath</key> 31 + <string>/tmp/slab-daemon.err</string> 32 + </dict> 33 + </plist>
+32
slab/settings-fragment.json
··· 1 + { 2 + "hooks": { 3 + "Stop": [ 4 + { 5 + "hooks": [ 6 + { "type": "command", "command": "@HOME@/.local/bin/claude-stop.sh", "async": true } 7 + ] 8 + } 9 + ], 10 + "SubagentStop": [ 11 + { 12 + "hooks": [ 13 + { "type": "command", "command": "/usr/bin/afplay @HOME@/.local/share/slab/sounds/ping.wav", "async": true } 14 + ] 15 + } 16 + ], 17 + "UserPromptSubmit": [ 18 + { 19 + "hooks": [ 20 + { "type": "command", "command": "@HOME@/.local/bin/claude-prompt-log.sh", "async": true } 21 + ] 22 + } 23 + ], 24 + "SessionStart": [ 25 + { 26 + "hooks": [ 27 + { "type": "command", "command": "pkill -f claude-ping-repeat.sh 2>/dev/null; pkill -f claude-sleep-schedule.sh 2>/dev/null; true", "async": true } 28 + ] 29 + } 30 + ] 31 + } 32 + }
slab/sounds/all-done.wav

This is a binary file and will not be displayed.

slab/sounds/ambient.wav

This is a binary file and will not be displayed.

slab/sounds/beep_1.wav

This is a binary file and will not be displayed.

slab/sounds/beep_2.wav

This is a binary file and will not be displayed.

slab/sounds/beep_3.wav

This is a binary file and will not be displayed.

slab/sounds/beep_4.wav

This is a binary file and will not be displayed.

slab/sounds/beep_5.wav

This is a binary file and will not be displayed.

slab/sounds/beep_6.wav

This is a binary file and will not be displayed.

slab/sounds/beep_7.wav

This is a binary file and will not be displayed.

slab/sounds/beep_8.wav

This is a binary file and will not be displayed.

slab/sounds/lid-open-ding.wav

This is a binary file and will not be displayed.

slab/sounds/lid-return.wav

This is a binary file and will not be displayed.

slab/sounds/lid-start.wav

This is a binary file and will not be displayed.

slab/sounds/ping.wav

This is a binary file and will not be displayed.

slab/sounds/ping_A.wav

This is a binary file and will not be displayed.

slab/sounds/ping_Ab.wav

This is a binary file and will not be displayed.

slab/sounds/ping_Bb.wav

This is a binary file and will not be displayed.

slab/sounds/ping_C.wav

This is a binary file and will not be displayed.

slab/sounds/ping_D.wav

This is a binary file and will not be displayed.

slab/sounds/ping_Eb.wav

This is a binary file and will not be displayed.

slab/sounds/ping_F.wav

This is a binary file and will not be displayed.

slab/sounds/ping_G.wav

This is a binary file and will not be displayed.

slab/sounds/sleep-tone.wav

This is a binary file and will not be displayed.

+8
slab/sudoers.d/slab-pmset.template
··· 1 + # Slab — allow passwordless pmset for the installing user. This lets 2 + # claude-sleep and the lid-ambient daemon toggle sleep state and trigger 3 + # display sleep / system sleep without interactive password prompts. 4 + # 5 + # Install with: install.sh --sudoers 6 + # Uninstall: remove /etc/sudoers.d/slab-pmset (requires sudo). 7 + 8 + @USER@ ALL=(root) NOPASSWD: /usr/bin/pmset
+76
slab/uninstall.sh
··· 1 + #!/bin/bash 2 + # slab uninstaller — undoes install.sh. Leaves generated data (sessions, logs) 3 + # alone by default; pass --purge to remove them too. 4 + 5 + set -euo pipefail 6 + 7 + SLAB_REPO=$(cd "$(dirname "$0")" && pwd) 8 + HOME_DIR=${HOME:-$(echo ~)} 9 + 10 + SLAB_HOME="$HOME_DIR/.local/share/slab" 11 + SLAB_BIN="$HOME_DIR/.local/bin" 12 + CLAUDE_DIR="$HOME_DIR/.claude" 13 + LAUNCH_AGENTS="$HOME_DIR/Library/LaunchAgents" 14 + PLIST_INSTALLED="$LAUNCH_AGENTS/computer.slab.daemon.plist" 15 + SUDOERS_FILE=/etc/sudoers.d/slab-pmset 16 + 17 + PURGE=0 18 + for arg in "$@"; do [[ "$arg" == "--purge" ]] && PURGE=1; done 19 + 20 + say() { printf '\033[1;36m• %s\033[0m\n' "$*"; } 21 + 22 + # ------------ stop + unload ------------ 23 + if [[ -f "$PLIST_INSTALLED" ]]; then 24 + say "unloading launchd agent" 25 + launchctl unload "$PLIST_INSTALLED" 2>/dev/null || true 26 + rm -f "$PLIST_INSTALLED" 27 + fi 28 + 29 + # kill stragglers 30 + pkill -f lid-ambient.sh 2>/dev/null || true 31 + pkill -f lid-reactive.py 2>/dev/null || true 32 + pkill -f slab-monitor.sh 2>/dev/null || true 33 + pkill -f claude-ping-repeat.sh 2>/dev/null || true 34 + pkill -f claude-sleep-schedule.sh 2>/dev/null || true 35 + 36 + # ------------ remove symlinks ------------ 37 + say "removing script symlinks from $SLAB_BIN" 38 + for f in "$SLAB_REPO/bin/"*; do 39 + base=$(basename "$f") 40 + dest="$SLAB_BIN/$base" 41 + if [[ -L "$dest" ]]; then rm -f "$dest"; fi 42 + done 43 + 44 + # ------------ Claude Code hooks: best-effort surgical removal ------------ 45 + target="$CLAUDE_DIR/settings.json" 46 + if [[ -f "$target" ]]; then 47 + say "removing slab hooks from $target" 48 + tmp=$(mktemp) 49 + jq 'def strip(events; cmd_pattern): 50 + reduce events[] as $ev (.; 51 + if .hooks[$ev] then 52 + .hooks[$ev] |= map( 53 + .hooks |= map(select(.command | test(cmd_pattern) | not)) | select(.hooks | length > 0) 54 + ) | (if (.hooks[$ev] | length) == 0 then del(.hooks[$ev]) else . end) 55 + else . end); 56 + strip(["Stop", "SubagentStop", "UserPromptSubmit", "SessionStart"]; 57 + "claude-(stop|ping-repeat|sleep-schedule|prompt-log)|slab/sounds/ping\\.wav") 58 + | if (.hooks | length) == 0 then del(.hooks) else . end' \ 59 + "$target" > "$tmp" && mv "$tmp" "$target" 60 + fi 61 + 62 + # ------------ sudoers ------------ 63 + if [[ -f "$SUDOERS_FILE" ]]; then 64 + say "removing sudoers rule (needs sudo)" 65 + sudo rm -f "$SUDOERS_FILE" 66 + fi 67 + 68 + # ------------ data ------------ 69 + if [[ $PURGE -eq 1 ]]; then 70 + say "purging $SLAB_HOME" 71 + rm -rf "$SLAB_HOME" 72 + else 73 + say "leaving $SLAB_HOME in place (use --purge to remove sessions/logs/venv/sounds)" 74 + fi 75 + 76 + say "uninstall complete"