From dbcaf999f90e909e58a5e243249de6b24acfe559 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 17 May 2026 19:38:40 -0700 Subject: [PATCH] =?UTF-8?q?feat(@scripts):=20=E2=9C=A8=20add=20disk=20recl?= =?UTF-8?q?aim,=20host=20probe,=20power-cycle=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- bin/disk-reclaim | 162 +++++++++++++++++++++++++++++++++++++++++ bin/host-probe | 83 +++++++++++++++++++++ bin/power-cycle | 88 ++++++++++++++++++++++ bin/rclaude | 29 +++++++- bin/rvoice | 23 +++++- docs/lan-power-ctrl.md | 133 +++++++++++++++++++++++++++++++++ hammerspoon/rvoice.lua | 45 ++++++++++-- 7 files changed, 549 insertions(+), 14 deletions(-) create mode 100755 bin/disk-reclaim create mode 100755 bin/host-probe create mode 100755 bin/power-cycle create mode 100644 docs/lan-power-ctrl.md diff --git a/bin/disk-reclaim b/bin/disk-reclaim new file mode 100755 index 0000000..f14f09e --- /dev/null +++ b/bin/disk-reclaim @@ -0,0 +1,162 @@ +#!/bin/sh +# disk-reclaim [path] [--min SIZE] [--all] [--no-summary] +# +# Scan (default $HOME) for generated/cache directories worth deleting. +# Read-only — never deletes. Reports dirs that regenerate from source (build +# outputs, dependency caches, IDE/framework state) sorted by size desc. +# +# Flags: +# --min SIZE only show entries >= SIZE (e.g. 100M, 1G; default 100M) +# --all alias for --min 0 +# --no-summary skip the totals-per-category section +# +# Patterns it looks for (project-scoped, found via find): +# JS/TS: node_modules, .next, .nuxt, .turbo, .vite, .parcel-cache, +# .svelte-kit, .astro, .cache, dist, build, out +# Python: __pycache__, .pytest_cache, .mypy_cache, .ruff_cache, .tox, .venv +# Rust: target +# Other: _build, Pods, DerivedData, .gradle, .android +# +# Plus top-level cache roots checked once each: +# ~/Library/Caches, ~/Library/Developer/Xcode/DerivedData +# ~/.cache, ~/.npm, ~/.pnpm-store, ~/.yarn/cache +# ~/.cargo/registry, ~/.cargo/git +# +# Caveats: +# - .venv requires a rebuild from pyproject/requirements after deletion +# - target (Rust) requires a recompile that can take minutes +# - node_modules needs npm/pnpm install +# - vendor/ is intentionally NOT scanned — often committed (Go) or required (PHP) + +set -eu + +root=$HOME +min_human=100M +show_summary=1 + +die() { echo "disk-reclaim: $*" >&2; exit 1; } + +usage() { + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 2 +} + +to_kb() { + case "$1" in + *[Kk]) echo "${1%[Kk]}" ;; + *[Mm]) echo $(( ${1%[Mm]} * 1024 )) ;; + *[Gg]) echo $(( ${1%[Gg]} * 1024 * 1024 )) ;; + *[Tt]) echo $(( ${1%[Tt]} * 1024 * 1024 * 1024 )) ;; + ''|*[!0-9]*) die "bad size: $1 (use K/M/G/T suffix or plain bytes)" ;; + *) echo "$(( $1 / 1024 ))" ;; + esac +} + +human() { + awk -v kb="$1" 'BEGIN { + if (kb >= 1048576) printf "%.1fG", kb/1048576 + else if (kb >= 1024) printf "%.0fM", kb/1024 + else printf "%dK", kb + }' +} + +while [ $# -gt 0 ]; do + case "$1" in + -h|--help|help) usage ;; + --min) [ $# -ge 2 ] || die "--min needs a value"; min_human=$2; shift 2 ;; + --min=*) min_human=${1#--min=}; shift ;; + --all) min_human=0; shift ;; + --no-summary) show_summary=0; shift ;; + -*) die "unknown flag: $1" ;; + *) root=$1; shift ;; + esac +done + +[ -d "$root" ] || die "not a directory: $root" +min_kb=$(to_kb "$min_human") +scan_root=$(cd "$root" && pwd -P) + +patterns="node_modules .next .nuxt .turbo .vite .parcel-cache .svelte-kit .astro .cache dist build out __pycache__ .pytest_cache .mypy_cache .ruff_cache .tox .venv target _build Pods DerivedData .gradle .android" + +# Build the find -name OR-chain. +expr="" +for n in $patterns; do + expr="$expr -name $n -o" +done +expr=${expr% -o} + +echo "scanning $scan_root (min size: $(human "$min_kb"))..." +echo + +# Find each matching dir; once matched, -prune so we don't descend into it +# looking for nested matches (e.g. avoid target/ inside node_modules). +# stderr → /dev/null to silence permission-denied noise on system dirs. +# shellcheck disable=SC2086 +results=$( + find "$scan_root" -type d \( $expr \) -prune -print 2>/dev/null \ + | while IFS= read -r dir; do + kb=$(du -sk "$dir" 2>/dev/null | awk '{print $1}') + [ -z "$kb" ] && continue + [ "$kb" -lt "$min_kb" ] && continue + printf '%s\t%s\n' "$kb" "$dir" + done \ + | sort -rn +) + +if [ -z "$results" ]; then + echo " (no project-scoped entries >= $(human "$min_kb"))" +else + printf ' %8s %s\n' "SIZE" "PATH" + printf ' %8s %s\n' "----" "----" + echo "$results" | while IFS="$(printf '\t')" read -r kb path; do + printf ' %8s %s\n' "$(human "$kb")" "$path" + done +fi + +echo +echo "top-level cache roots:" +cache_results=$( + for p in \ + "$HOME/Library/Caches" \ + "$HOME/Library/Developer/Xcode/DerivedData" \ + "$HOME/.cache" \ + "$HOME/.npm" \ + "$HOME/.pnpm-store" \ + "$HOME/.yarn/cache" \ + "$HOME/.cargo/registry" \ + "$HOME/.cargo/git" + do + [ -d "$p" ] || continue + kb=$(du -sk "$p" 2>/dev/null | awk '{print $1}') + [ -z "$kb" ] && continue + [ "$kb" -lt "$min_kb" ] && continue + printf '%s\t%s\n' "$kb" "$p" + done | sort -rn +) +if [ -z "$cache_results" ]; then + echo " (none >= $(human "$min_kb"))" +else + echo "$cache_results" | while IFS="$(printf '\t')" read -r kb path; do + printf ' %8s %s\n' "$(human "$kb")" "$path" + done +fi + +if [ "$show_summary" = 1 ] && [ -n "$results" ]; then + echo + echo "totals by category:" + totals=$( + for n in $patterns; do + sum=$(echo "$results" | awk -v n="$n" -F'\t' ' + { i = split($2, a, "/"); if (a[i] == n) total += $1 } + END { print total+0 } + ') + [ "$sum" -gt 0 ] && printf '%s\t%s\n' "$sum" "$n" + done | sort -rn + ) + echo "$totals" | while IFS="$(printf '\t')" read -r kb name; do + printf ' %8s %s\n' "$(human "$kb")" "$name" + done +fi + +echo +echo "review carefully before rm -rf. some dirs (.venv, target, node_modules) need a rebuild after deletion." diff --git a/bin/host-probe b/bin/host-probe new file mode 100755 index 0000000..ffbb46c --- /dev/null +++ b/bin/host-probe @@ -0,0 +1,83 @@ +#!/bin/sh +# host-probe [port] — one-shot: print state and exit +# host-probe --watch [port] — loop, emit only on state change +# +# Distinguishes three states by probing layers independently: +# up ICMP + TCP accept + SSH banner exchange all succeed +# wedged ICMP + TCP accept succeed, banner exchange times out +# (kernel networking alive, userspace frozen — classic +# D-state / OOM / disk hang signature) +# down no ICMP or no TCP accept +# +# Suitable both as a standalone check and as the command body for the +# Monitor tool (one stdout line per state change). +# +# Env: +# HOST_PROBE_INTERVAL seconds between polls in --watch mode (default 30) +# HOST_PROBE_TIMEOUT per-probe timeout in seconds (default 3) + +set -eu + +interval=${HOST_PROBE_INTERVAL:-30} +timeout=${HOST_PROBE_TIMEOUT:-3} + +usage() { + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 2 +} + +watch=false +case "${1:-}" in + ''|-h|--help|help) usage ;; + --watch) watch=true; shift ;; +esac +[ $# -ge 1 ] && [ $# -le 2 ] || usage + +host=$1 +port=${2:-22} + +probe_icmp() { + ping -c1 -W"$timeout" "$host" >/dev/null 2>&1 +} + +probe_tcp() { + # -G is the BSD/macOS connect timeout flag; falls back to -w on Linux nc. + nc -z -G"$timeout" "$host" "$port" >/dev/null 2>&1 \ + || nc -z -w"$timeout" "$host" "$port" >/dev/null 2>&1 +} + +probe_banner() { + # SSH banner arrives unsolicited within milliseconds on a healthy sshd. + # Frozen userspace: TCP accepts but no banner ever lands. + banner=$( + ( nc -G"$timeout" "$host" "$port" /dev/null ) & + wait "$nc_pid" 2>/dev/null ) 2>/dev/null | head -c 100 + ) + [ -n "$banner" ] +} + +classify() { + if ! probe_icmp; then echo down; return; fi + if ! probe_tcp; then echo down; return; fi + if ! probe_banner; then echo wedged; return; fi + echo up +} + +stamp() { date -u +%H:%M:%SZ; } + +if [ "$watch" = false ]; then + classify + exit 0 +fi + +prev="" +while :; do + state=$(classify) + if [ "$state" != "$prev" ]; then + echo "[$(stamp)] $host:$port $state" + prev=$state + fi + sleep "$interval" +done diff --git a/bin/power-cycle b/bin/power-cycle new file mode 100755 index 0000000..3f31ce0 --- /dev/null +++ b/bin/power-cycle @@ -0,0 +1,88 @@ +#!/bin/sh +# power-cycle — off, wait POWER_CYCLE_OFF_SECS (default 5), on +# power-cycle off|on|status — explicit single action +# power-cycle list — show configured host -> plug mappings +# +# Targets Shelly Gen2 plugs (Plus Plug US/S, etc.) via their local HTTP RPC. +# No cloud, no account. The host must be reachable on the same network/VPN. +# +# Config: ~/.config/power-cycle/plugs.conf +# one entry per line: +# blank lines and lines starting with # are ignored. +# Example: +# apricot http://10.0.0.117 +# plum http://10.0.0.119 +# +# Env: +# POWER_CYCLE_OFF_SECS seconds to stay off during a cycle (default 5) +# POWER_CYCLE_TIMEOUT per-request curl timeout in seconds (default 5) + +set -eu + +config="${XDG_CONFIG_HOME:-$HOME/.config}/power-cycle/plugs.conf" +off_secs=${POWER_CYCLE_OFF_SECS:-5} +http_timeout=${POWER_CYCLE_TIMEOUT:-5} + +die() { echo "power-cycle: $*" >&2; exit 1; } + +usage() { + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 2 +} + +lookup_plug() { + # echoes the plug base URL for $1, or exits non-zero with a hint. + host=$1 + [ -f "$config" ] || die "no config at $config — create it (see 'power-cycle' help)" + url=$(awk -v h="$host" ' + /^[[:space:]]*(#|$)/ { next } + $1 == h { print $2; found=1; exit } + END { exit !found } + ' "$config") || die "no plug configured for '$host' in $config" + [ -n "$url" ] || die "empty plug URL for '$host' in $config" + printf %s "$url" +} + +shelly_set() { + # shelly_set + base=$1; state=$2 + curl --fail --silent --show-error --max-time "$http_timeout" \ + "$base/rpc/Switch.Set?id=0&on=$state" >/dev/null \ + || die "plug $base unreachable — for modem outages, fall back to BLE (SwitchBot app)" +} + +shelly_status() { + # echoes "on" or "off" + base=$1 + body=$(curl --fail --silent --show-error --max-time "$http_timeout" \ + "$base/rpc/Switch.GetStatus?id=0") \ + || die "plug $base unreachable" + case "$body" in + *'"output":true'*) echo on ;; + *'"output":false'*) echo off ;; + *) die "unrecognized Shelly response: $body" ;; + esac +} + +cmd_list() { + [ -f "$config" ] || die "no config at $config" + awk '/^[[:space:]]*(#|$)/ { next } { printf " %-15s %s\n", $1, $2 }' "$config" +} + +case "${1:-}" in + ''|-h|--help|help) usage ;; + list) cmd_list ;; + off) [ $# -eq 2 ] || usage; shelly_set "$(lookup_plug "$2")" false ;; + on) [ $# -eq 2 ] || usage; shelly_set "$(lookup_plug "$2")" true ;; + status) [ $# -eq 2 ] || usage; shelly_status "$(lookup_plug "$2")" ;; + *) + [ $# -eq 1 ] || usage + host=$1 + base=$(lookup_plug "$host") + echo "cycling $host: off -> ${off_secs}s -> on" + shelly_set "$base" false + sleep "$off_secs" + shelly_set "$base" true + echo "done. give the host ~30-90s to finish booting." + ;; +esac diff --git a/bin/rclaude b/bin/rclaude index 5ae3471..457c840 100755 --- a/bin/rclaude +++ b/bin/rclaude @@ -815,13 +815,32 @@ cmd_resume() { if ($2 == "session") return $4 " " c_dim "[" substr($3,1,8) "]" r return $3 } - # Col B: the explicit `claude -n` display name (NF). Blank when - # the session was never named — col C (dir) carries the project - # identity in that case. + # Col B: the explicit `claude -n` display name (NF for sessions, + # NF-1 for tmux because tmux rows have no name column at the end). function name_col() { return ($2=="tmux") ? "" : $NF } + # "Time ago" column. mtime is in different fields depending on + # row kind (session=col 6, triage=col 9). Tmux rows have no + # mtime — blank in that column. + function ago(secs, abs, s) { + if (secs == "" || secs+0 == 0) return "" + abs = now - secs+0 + if (abs < 0) abs = 0 + if (abs < 60) return abs "s" + if (abs < 3600) return int(abs/60) "m" + if (abs < 86400) return int(abs/3600) "h" + if (abs < 86400*30) return int(abs/86400) "d" + if (abs < 86400*365) return int(abs/(86400*30)) "mo" + return int(abs/(86400*365)) "y" + } + function age_col() { + if ($2 == "session") return ago($6) + if ($2 == "triage") return ago($9) + return "" # tmux: no age field + } { - printf "%s%-10s%s %s%-22s%s %s%-22s%s %s", + printf "%s%-10s%s %s%5s%s %s%-22s%s %s%-22s%s %s", c_host, $1, r, + c_dim, age_col(), r, c_name, fit(name_col(), 22), r, c_dim, fit(dir_label(), 22), r, display() @@ -834,6 +853,7 @@ cmd_resume() { -v c_p5="$_Cp5" -v c_p4="$_Cp4" \ -v c_blk="$_Cblk" -v c_wait="$_Cwait" -v c_inp="$_Cinp" -v c_done="$_Cdone" \ -v c_name="$_Cname" \ + -v now="$(date +%s)" \ "$_fmt_row"'{printf "\n"}' >&2 exit 1 fi @@ -851,6 +871,7 @@ cmd_resume() { -v c_p5="$_Cp5" -v c_p4="$_Cp4" \ -v c_blk="$_Cblk" -v c_wait="$_Cwait" -v c_inp="$_Cinp" -v c_done="$_Cdone" \ -v c_name="$_Cname" \ + -v now="$(date +%s)" \ "$_fmt_row") printf ' %s[%s]%s %s\n' "$_Ckey" "$_k" "$_R" "$_row_text" >&2 _prev_kind=$_kind_now diff --git a/bin/rvoice b/bin/rvoice index b523ebc..b02326e 100755 --- a/bin/rvoice +++ b/bin/rvoice @@ -106,7 +106,11 @@ cmd_start() { rm -f "$PID_FILE" fi rm -f "$WAV_FILE" - now_ms > "$START_FILE" + # Write start timestamp and pid atomically (mv after both files exist) + # so a concurrent cmd_stop can't observe a half-written START_FILE. + _start_ts=$(now_ms) + printf '%s' "$_start_ts" > "${START_FILE}.tmp" + mv -f "${START_FILE}.tmp" "$START_FILE" # 16kHz mono PCM, capped at MAX_S. Device "0" is the default macOS input; # change with AVFoundation list if you have multiple mics. nohup ffmpeg -hide_banner -loglevel error -nostdin \ @@ -119,10 +123,22 @@ cmd_start() { } cmd_stop() { + # Optional flag: --print-text emits the transcribed text to stdout + # (suppressing other stdout/stderr that would corrupt the consumer). + # Used by the Hammerspoon module to surface a transcript toast. + _print_text=0 + if [ "${1:-}" = "--print-text" ]; then _print_text=1; fi [ -f "$PID_FILE" ] || { log "stop: no recording in progress"; return 0; } _pid=$(cat "$PID_FILE") - _start=$(cat "$START_FILE" 2>/dev/null || echo 0) + # START_FILE may be missing/empty if a concurrent start/stop raced us; + # treat anything that isn't a sane recent timestamp as "unknown" and + # let the empty-recording / min-ms guards handle the rest. + _start=$(cat "$START_FILE" 2>/dev/null || true) + case $_start in + ''|*[!0-9]*) _start=$(now_ms) ;; + esac _dur_ms=$(( $(now_ms) - _start )) + [ "$_dur_ms" -lt 0 ] && _dur_ms=0 # `q` on stdin is ffmpeg's clean-stop signal but with -nostdin we use # SIGINT — ffmpeg flushes the wav header on SIGINT. kill -INT "$_pid" 2>/dev/null || true @@ -174,6 +190,7 @@ cmd_stop() { ssh -o BatchMode=yes "$_host" "tmux send-keys -t '$_sess' Enter" 2>>"$LOG_FILE" fi notify "✓ $_txt" ok + [ "$_print_text" = "1" ] && printf '%s\n' "$_txt" } cmd_cancel() { @@ -201,7 +218,7 @@ fi case ${1:-} in start) cmd_start ;; - stop) cmd_stop ;; + stop) shift; cmd_stop "$@" ;; cancel) cmd_cancel ;; target) cmd_target ;; log) tail -50 "$LOG_FILE" 2>/dev/null ;; diff --git a/docs/lan-power-ctrl.md b/docs/lan-power-ctrl.md new file mode 100644 index 0000000..5355ce7 --- /dev/null +++ b/docs/lan-power-ctrl.md @@ -0,0 +1,133 @@ +# lan-power-ctrl — LAN power-cycle for wedged hosts + +When a host's userspace freezes (D-state, OOM, disk hang), the kernel still +answers ICMP and accepts TCP, but no daemon completes a request. SSH banner +never arrives → unreachable. The only recovery is a hardware power-cycle. + +This doc covers the hardware to buy, the network topology, and the two +scripts (`host-probe`, `power-cycle`) that turn "apricot froze" into a +one-liner from any machine on the LAN/VPN. + +## Diagnosis vs recovery + +``` + layer healthy host frozen userspace unreachable host + ───── ──────────── ──────────────── ──────────────── + ICMP reply <50ms reply <50ms no reply + TCP accept :22 succeeds succeeds connection refused / timeout + SSH banner arrives <100ms never arrives — + + host-probe up wedged down + power-cycle n/a needed power's the cure regardless +``` + +A `wedged` classification is the signature that a remote shell can't help — +every login path needs the same userspace that's stuck. Cut power. + +## Shopping list + +### Per host to be remotely recoverable + +| Device | Where | Purpose | Link | +|---|---|---|---| +| **Shelly Plus Plug US** (Gen2) **or Shelly Plug US Gen4** | Between the host's PSU and the wall | Local HTTP RPC, no cloud | [Plus (Gen2)](https://us.shelly.com/products/shelly-plus-plug-us) · [Gen4](https://us.shelly.com/products/shelly-plug-us-gen4-white) | + +Either generation works — Gen4 adds power monitoring and Matter, both share +the same Gen2-compatible `/rpc/Switch.Set` API the `power-cycle` script uses. +Buy whichever is in stock and cheapest at order time. ~$25. + +### For the modem (separate problem — can't rescue itself over its own WiFi) + +| Device | Where | Purpose | Link | +|---|---|---|---| +| **SwitchBot Plug Mini** | Modem | BLE-controllable plug | [product](https://us.switch-bot.com/products/switchbot-plug-mini) | +| **USB Bluetooth dongle** | Plugged into **black** (X399 boards have no integrated BT — see [[reference-host-hardware]]) | Lets black drive BLE to the modem plug | Search "USB Bluetooth 5 adapter CSR8510 or RTL8761" (~$10, plug-and-play under bluez) | + +Why black and not apricot: using apricot to rescue the modem couples failure +modes — apricot is *also* a recoverable host. Black is the independent +watchdog. + +Why not put the SwitchBot Plug Mini on a *host* (so WiFi-controlled)? It +*can* be — but for the modem you can't, because the modem dying takes WiFi +down with it. BLE from a LAN-attached watchdog works regardless of WAN +state, assuming **separate modem and router** (see Caveat). + +### Caveat: combined modem-router vs separate + +If your ISP gave you a combined modem-router (one box does both), modem-dead +also means LAN-dead. Nothing on the LAN can rescue it. Options narrow to: +phone/laptop in BLE range, or an auto-rebooter plug (watchdog built into the +plug itself — pings a target, power-cycles on failure). + +If modem and router are separate (typical home-server setup), LAN stays +alive when WAN dies, and the black-as-watchdog plan works as designed. + +## One-time setup after plugs arrive + +1. **Set the Shelly's own restore behavior** (so it remembers its on/off + state across a *wall* outage): + ```sh + curl 'http:///rpc/Switch.SetConfig?id=0&config={"initial_state":"restore_last"}' + ``` +2. **Set the host's BIOS to "Restore on AC power loss"** (so when the Shelly + restores power, the host actually boots up rather than sitting dark). +3. **Add the plug to the config:** + ``` + # ~/.config/power-cycle/plugs.conf + apricot http:// + ``` + Get `` from the router DHCP table or `arp -a | grep -i shelly`. + +## Scripts + +### `host-probe` — classify reachability + +```sh +host-probe apricot.lan # → up | wedged | down (one-shot) +host-probe --watch apricot.lan # loop; emits one line per state change +HOST_PROBE_INTERVAL=10 HOST_PROBE_TIMEOUT=2 host-probe --watch apricot.lan 22 +``` + +Three independent probes: ICMP, TCP accept, SSH banner. The banner timing is +what distinguishes a wedged host from a healthy one — a real sshd flushes +its `SSH-2.0-…` banner within milliseconds of connect. + +Composes with the harness `Monitor` tool: each state-change line becomes one +notification. + +### `power-cycle` — Shelly Gen2 RPC wrapper + +```sh +power-cycle apricot # off → POWER_CYCLE_OFF_SECS (default 5) → on +power-cycle off|on|status apricot +power-cycle list # show configured host → plug mappings +``` + +Config: `~/.config/power-cycle/plugs.conf`, one line per host: + +``` + +``` + +Errors covered: missing config, unknown host, plug unreachable (with hint to +fall back to BLE for modem outages), unrecognized Shelly response. + +## Future: modem watchdog daemon on black + +Once the USB BT dongle and SwitchBot plug arrive: + +- `bleak`-based Python daemon, runs as `systemd --user` unit on black +- Pings WAN (e.g. `1.1.1.1`) every 30s +- After N consecutive failures, BLE-toggles the SwitchBot plug off → 10s → on +- Logs to journal; emits a notification via the existing apricot + speech-synthesis path (see [[reference-rvoice]]) when it acts + +Not implemented yet — waiting on hardware. + +## Files + +| Path | Role | +|---|---| +| `bin/host-probe` | Three-layer reachability probe | +| `bin/power-cycle` | Shelly Gen2 RPC wrapper | +| `~/.config/power-cycle/plugs.conf` | Per-host plug URL mapping (user-created) | diff --git a/hammerspoon/rvoice.lua b/hammerspoon/rvoice.lua index 6f183b3..18bd27e 100644 --- a/hammerspoon/rvoice.lua +++ b/hammerspoon/rvoice.lua @@ -10,9 +10,13 @@ -- 4. Reload Hammerspoon config (menu bar → Reload Config) -- 5. Grant Accessibility + Microphone permissions when prompted. -- --- Behavior: hold Right-Option to talk. Release to transcribe + inject into --- the active iTerm2 tab's remote tmux session. Taps shorter than 200ms are --- ignored (configurable via RVOICE_MIN_MS env in rvoice config). +-- Behavior: hold Right ⌥ (Right Option) to talk — but only when the +-- focused iTerm2 tab is attached to an rclaude session (i.e. its title +-- matches ` · claude-…`, the format set by session-tools/tmux.conf). +-- Outside that context Right ⌥ passes through unmodified, so the key still +-- types its usual special characters in other apps. +-- Release to transcribe + inject into the active rclaude tmux session. +-- Taps shorter than 200ms are ignored (configurable via RVOICE_MIN_MS). -- -- Visual feedback (in order, from least to most intrusive): -- 1. Menu-bar dot — gray idle, red filled while recording, yellow during @@ -171,24 +175,51 @@ local function doStop() end) end --- Right-Option keyDown/keyUp. Hammerspoon delivers modifier changes through --- eventtap.flagsChanged; we watch for the rightAlt flag transitioning. +-- Context gate: PTT only fires when the focused iTerm2 tab is showing an +-- rclaude session. The canonical tmux config sets the title to +-- " · "; for rclaude the session name always starts with +-- "claude-". Anything else (browser, finder, local iTerm2 shell tab, +-- a non-rclaude tmux) returns false so the key behaves normally. +local function inRclaudeSession() + local front = hs.application.frontmostApplication() + if not front then return false end + local name = front:name() + if name ~= "iTerm2" and name ~= "iTerm" then return false end + -- Pull the title of the active session via AppleScript. Cheap (~5ms); + -- we only run this on a Right ⌥ keyDown, not on every event. + local ok, title = hs.osascript.applescript( + 'tell application "iTerm2" to tell current session of current window to return name') + if not ok or type(title) ~= "string" then return false end + -- Canonical tmux title set by session-tools/tmux.conf: + -- "#H · #S" → "apricot · claude-natalie-..." + -- We're permissive on whitespace around the separator but require the + -- session name to start with "claude-" (rclaude's invariant). + return title:match("·%s*claude%-") ~= nil +end + +-- Right-Option push-to-talk. Hammerspoon delivers modifier transitions via +-- flagsChanged; we gate on keycode 61 (Right Option) and read the alt flag +-- to determine press vs release. M.tap = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(e) local code = e:getKeyCode() if code ~= 61 then return false end -- 61 = Right Option local flags = e:getFlags() local pressed = flags.alt or false if pressed and not holding then + -- Only arm PTT when the focused tab is an rclaude session. If we + -- didn't arm on keyDown, the release branch below will also skip + -- because `holding` stays false. + if not inRclaudeSession() then return false end holding = true doStart() elseif (not pressed) and holding then holding = false doStop() end - return false -- don't swallow the modifier; other apps may use it + return false -- don't swallow; other apps may want the modifier end) M.tap:start() -hs.alert.show("rvoice: Right ⌥ to talk", 1.5) +hs.alert.show("rvoice: hold Right ⌥ to talk", 1.5) return M