#!/bin/sh # meathook session reaper — ends sessions whose terminal is gone (spec §11). # # Hooks cannot see a closed terminal. SessionEnd fires on a clean exit; it does # not fire when the window is closed, the process is killed, or an SSH # connection drops. The status function deliberately will not paper over that: # `blocked`, `attention` and `idle` all outrank the deadman timers (§5.1, §5.2) # because a quiet session is usually a waiting one, so a ghost sits on the # board forever. Only ground truth that the process is gone can clear it. # # So this job asks the harness which sessions are actually running, and sends # `session_end` for the ones this machine still has open state for. # # EVERYTHING it can send (spec §8 allowlist): event type, event_id, # occurred_at, session_key, machine and project labels, harness name, and the # literal reason "reaped". It reads no transcript, no prompt, no output — only # the session ids of running processes and the state files the hook wrote. # # It FAILS CLOSED. If the harness cannot be asked — not installed, not on PATH, # a listing it does not recognise — it sends nothing at all. A reaper that # reads "cannot tell" as "nothing is running" ends every session on the # machine, so the only safe default is to do nothing. # # Usage: # meathook-reap.sh [--dry-run] report or send # meathook-reap.sh --install run it every minute from now on # meathook-reap.sh --uninstall stop running it # # Dependencies: curl, uuidgen, and the claude CLI. set -u DRY=0 ACTION=run while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1; shift ;; --install) ACTION=install; shift ;; --uninstall) ACTION=uninstall; shift ;; -h|--help) sed -n '2,28p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done STATE_DIR="$HOME/.meathook/state" # --- scheduling ------------------------------------------------------------- # Periodic, not a resident daemon: the whole job is one listing and a diff, and # a crashed daemon is another thing that fails silently. Once a minute is the # floor both schedulers offer, and it is affordable: the listing costs ~0.2 s # and one short-lived process, which is why it can be this frequent without a # resident daemon to amortise it. SELF=$(cd "$(dirname "$0")" 2>/dev/null && pwd)/$(basename "$0") PLIST="$HOME/Library/LaunchAgents/ai.meathook.reap.plist" install_job() { if [ "$(uname -s)" = "Darwin" ]; then mkdir -p "$(dirname "$PLIST")" # launchd starts jobs with a minimal PATH, so name the places the CLI # actually lives rather than inheriting a login shell's environment. cat >"$PLIST" < Labelai.meathook.reap ProgramArguments /bin/sh$SELF EnvironmentVariables PATH$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin StartInterval60 RunAtLoad EOF launchctl unload "$PLIST" 2>/dev/null || true launchctl load "$PLIST" || { echo "launchctl load failed" >&2; exit 1; } echo "Installed: launchd job ai.meathook.reap, every 60s." else command -v crontab >/dev/null 2>&1 || { echo "no crontab; schedule $SELF yourself" >&2; exit 1; } (crontab -l 2>/dev/null | grep -v 'meathook-reap'; echo "* * * * * /bin/sh $SELF") | crontab - echo "Installed: crontab entry, every minute." fi echo "Check it with: sh $SELF --dry-run" } uninstall_job() { if [ "$(uname -s)" = "Darwin" ]; then launchctl unload "$PLIST" 2>/dev/null || true rm -f "$PLIST" else crontab -l 2>/dev/null | grep -v 'meathook-reap' | crontab - 2>/dev/null || true fi echo "Removed. Sessions whose terminal closes will stay on the board again." } case "$ACTION" in install) install_job; exit 0 ;; uninstall) uninstall_job; exit 0 ;; esac # --- config ----------------------------------------------------------------- # No per-worktree file: this runs from a scheduler, not inside a project. conf() { [ -f "$2" ] && sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$2" | head -n 1; } cfg() { conf "$1" "$HOME/.meathook/config.json"; } URL="${MEATHOOK_URL:-$(cfg url)}" TOKEN="${MEATHOOK_TOKEN:-$(cfg token)}" [ -n "$URL" ] && [ -n "$TOKEN" ] || exit 0 MACHINE=$(cfg machine); [ -n "$MACHINE" ] || MACHINE=$(hostname -s) [ -d "$STATE_DIR" ] || exit 0 # One run at a time. A scheduler that stacks runs on top of a hung listing # would keep launching processes; mkdir is the atomic test-and-set. LOCK="$STATE_DIR/.reap.lock" if ! mkdir "$LOCK" 2>/dev/null; then # Ten minutes without finishing means the holder died without cleaning up. [ -n "$(find "$LOCK" -maxdepth 0 -mmin +10 2>/dev/null)" ] || exit 0 rmdir "$LOCK" 2>/dev/null || true mkdir "$LOCK" 2>/dev/null || exit 0 fi trap 'rmdir "$LOCK" 2>/dev/null' EXIT INT TERM # --- who is actually alive -------------------------------------------------- CLAUDE="${MEATHOOK_CLAUDE:-$(command -v claude 2>/dev/null || true)}" if [ -z "$CLAUDE" ]; then for c in "$HOME/.local/bin/claude" "$HOME/.claude/local/claude" \ /opt/homebrew/bin/claude /usr/local/bin/claude; do if [ -x "$c" ]; then CLAUDE="$c"; break; fi done fi # A named CLI that is not there is "cannot tell", not "look elsewhere": falling # back to a discovered binary would answer with a harness the operator did not # name, and reporting it as a failed listing hides which of the two broke. if [ -z "$CLAUDE" ] || [ ! -x "$CLAUDE" ]; then [ "$DRY" -eq 1 ] && echo "claude CLI not found — nothing sent" exit 0 fi # The listing is filtered by process liveness on the harness side: an entry # whose pid is gone is not returned, which is exactly the fact needed here. LISTING=$("$CLAUDE" agents --json 2>/dev/null) || { [ "$DRY" -eq 1 ] && echo "listing failed — nothing sent"; exit 0; } case "$LISTING" in '['*']') ;; # Not a JSON array: an old CLI, an error page, a login prompt. Unknown is # not empty — see the fail-closed note at the top. *) [ "$DRY" -eq 1 ] && echo "unrecognised listing — nothing sent"; exit 0 ;; esac LIVE=$(printf '%s' "$LISTING" | grep -o '"sessionId"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/') # --- reap ------------------------------------------------------------------- clean() { tr -d '"\\' ; } field() { [ -n "$2" ] && printf '"%s":"%s",' "$1" "$(printf '%s' "$2" | clean)"; } for OPEN in "$STATE_DIR"/*.open; do [ -f "$OPEN" ] || continue # no matches: the glob stays literal SID=$(basename "$OPEN" .open) printf '%s\n' "$LIVE" | grep -qxF "$SID" && continue SESSION_KEY=$(sed -n 1p "$OPEN") PROJECT=$(sed -n 2p "$OPEN") STATE="$STATE_DIR/$SID" if [ -z "$SESSION_KEY" ]; then rm -f "$OPEN"; continue; fi if [ "$DRY" -eq 1 ]; then echo "would end $SID (${PROJECT:-?})" continue fi BODY="{\"v\":1,\ \"event_id\":\"$(uuidgen | tr 'A-Z' 'a-z')\",\ \"session_key\":\"$SESSION_KEY\",\ \"occurred_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\ $(field machine "$MACHINE")\ $(field project "$PROJECT")\ \"harness\":\"claude-code\",\"type\":\"session_end\",\"payload\":{\"reason\":\"reaped\"}}" CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 --retry 1 \ -X POST "$URL/v1/events" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d "$BODY" 2>/dev/null || true) # Forget the session only once the board has it. A board that is down must # cost a later reap, not a lost one — the state file is the only record that # this session was ever open. case "$CODE" in 2*) rm -f "$STATE.open" "$STATE.blocked" "$STATE.last" "$STATE.count" "$STATE.summary" ;; esac done exit 0