feat(@scripts): add disk reclaim, host probe, power-cycle tools

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-17 19:38:40 -07:00
parent 1c575ad263
commit dbcaf999f9
7 changed files with 549 additions and 14 deletions

162
bin/disk-reclaim Executable file
View file

@ -0,0 +1,162 @@
#!/bin/sh
# disk-reclaim [path] [--min SIZE] [--all] [--no-summary]
#
# Scan <path> (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."

83
bin/host-probe Executable file
View file

@ -0,0 +1,83 @@
#!/bin/sh
# host-probe <host> [port] — one-shot: print state and exit
# host-probe --watch <host> [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 &
nc_pid=$!
( sleep "$timeout"; kill "$nc_pid" 2>/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

88
bin/power-cycle Executable file
View file

@ -0,0 +1,88 @@
#!/bin/sh
# power-cycle <host> — off, wait POWER_CYCLE_OFF_SECS (default 5), on
# power-cycle off|on|status <host> — 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: <host> <plug-base-url>
# 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-url> <true|false>
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

View file

@ -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

View file

@ -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 ;;

133
docs/lan-power-ctrl.md Normal file
View file

@ -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://<plug>/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://<plug-ip>
```
Get `<plug-ip>` 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:
```
<host> <plug-base-url>
```
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) |

View file

@ -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 `<host> · 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
-- "<host> · <session>"; 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