#!/bin/sh # Report what Claude Code is doing to whichever terminal it is running in. # # Called from Claude Code hooks with the state as the first argument: # # playpen-status.sh busy # started working # playpen-status.sh input # blocked on the user # playpen-status.sh idle # finished # playpen-status.sh error # stopped on an error # # The state is written as OSC 9;4 — the ConEmu progress protocol — to the pty # Claude Code is running on, and on `busy` the prompt is written as an OSC 0 # title alongside it. # # Why a pty and not a socket. The bytes then arrive in that pane and no other, # with nothing to configure and no way for two concurrent sessions to be # confused for each other. It survives a VM boundary too: run Claude inside a # microVM and the guest's console passes these through to the host pty # unchanged, which a unix socket on the host could not do. # # Nothing here is Playpen-specific. OSC 9;4 is what Windows Terminal, ConEmu # and Ghostty already use for taskbar progress, so these hooks light up those # terminals too, and any terminal that ignores it is unharmed. set -u state=${1:-idle} # --------------------------------------------------------------------------- # Read the event. # # Claude Code hands the hook its event as JSON on stdin. Both of the states # that care about it read it here, before anything has been written, because # the payload decides what — if anything — this call should report at all. # # Guarded on a tty so that running this by hand still works: stdin is then the # keyboard, and `cat` would sit there waiting for a payload nobody is going to # type. payload="" if [ "$state" = busy ] || [ "$state" = input ]; then [ -t 0 ] || payload=$(cat 2>/dev/null) || payload="" fi # Pull a string field out of the payload. jq when it is there, and otherwise a # sed fallback that gives up on a value containing an escaped quote — the # common case handled badly rather than the rare case handled wrongly. Both # callers have a safe default for the empty answer. json_str() { [ -n "$payload" ] || return 0 if command -v jq >/dev/null 2>&1; then printf '%s' "$payload" | jq -r --arg k "$1" '.[$k] // ""' 2>/dev/null else printf '%s' "$payload" \ | sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' fi } # The Notification event is not one event. Claude Code raises it for permission # prompts, for elicitations, for auth results, for a subagent finishing — and # for `idle_prompt`, a 60-second timer that fires whenever a session has been # sitting with nothing running. Reporting every one of them as "blocked on # you" is what makes a finished, cleared session light up amber a minute after # you have walked away from it. # # So report the kind, not the event. An absent field means a caller that isn't # Claude Code, which keeps the argument's plain meaning. if [ "$state" = input ]; then case $(json_str notification_type) in permission_prompt | elicitation_dialog | agent_needs_input | "") ;; # The opposite claim: the session is sitting idle. Worth saying, because it # clears a stale amber left by a prompt that was answered somewhere else. idle_prompt) state=idle ;; # Informational — auth results, a completed elicitation, a subagent # finishing while the session itself is still working. None of them change # whether the pane wants you, so leave it showing whatever it shows. *) exit 0 ;; esac fi # --------------------------------------------------------------------------- # Find the pty to write to. # # Not /dev/tty, which is the obvious answer and the wrong one: Claude Code # starts each hook in its own session, so a hook has no controlling terminal # and opening /dev/tty fails with ENXIO. Worse, `[ -w /dev/tty ]` still says # yes — it stats the path, whose mode is 0666, rather than opening it — so # guarding on that reports success and then writes into nothing. # # The pty is still one hop away: it is on Claude Code's own standard fds. So # test our controlling terminal by actually opening it, and otherwise walk up # the process tree for the nearest ancestor holding a pty open. That nearest # ancestor is the right answer even when something else is in between: a # Claude running inside tmux inside a pane finds tmux's pty, which is where # its output is really going. tty_target="" if (: > /dev/tty) 2>/dev/null; then tty_target=/dev/tty fi if [ -z "$tty_target" ] && [ -d /proc ]; then pid=$PPID hops=0 while [ "$hops" -lt 16 ] && [ -n "$pid" ] && [ "$pid" != 0 ] && [ "$pid" != 1 ]; do for fd in 0 1 2; do link=$(readlink "/proc/$pid/fd/$fd" 2>/dev/null) || continue case "$link" in # Guarded to terminal devices before opening it for write, so this # can never truncate a regular file an fd happened to point at. /dev/pts/[0-9]* | /dev/tty[0-9]*) if (: > "$link") 2>/dev/null; then tty_target=$link break fi ;; esac done [ -n "$tty_target" ] && break # Walk to the parent. The comm field of /proc/pid/stat is parenthesised # and may itself contain spaces, so cut through it rather than counting # fields from the start: after the trim, $2 is the ppid. stat=$(cat "/proc/$pid/stat" 2>/dev/null) || break # shellcheck disable=SC2086 set -- ${stat#*") "} pid=${2:-} hops=$((hops + 1)) done fi # No /proc to walk: ask ps for the parent's terminal instead. Linux spells it # "pts/4" and macOS "s004", so try it both as given and with the tty prefix. if [ -z "$tty_target" ]; then name=$(ps -o tty= -p "$PPID" 2>/dev/null | tr -d ' \n') case "$name" in "" | "?" | "??") ;; *) for cand in "/dev/$name" "/dev/tty$name"; do if (: > "$cand") 2>/dev/null; then tty_target=$cand break fi done ;; esac fi # Nothing anywhere to report to — headless, in CI, or a harness that detached # us from every terminal. A hook must never be the thing that breaks a # session, so leave quietly. [ -n "$tty_target" ] || exit 0 case "$state" in busy) code=3 ;; # indeterminate input) code=4 ;; # paused error) code=2 ;; # error *) code=0 ;; # removed esac # Every write goes to the pty explicitly. stdout belongs to Claude Code, which # parses it as the hook's JSON result; an escape sequence written there would # corrupt the hook protocol rather than reach the terminal. printf '\033]9;4;%s\007' "$code" > "$tty_target" 2>/dev/null || true # Only a starting turn carries a task worth naming. The other states leave the # title alone so the shell's own title comes back when the session ends. [ "$state" = busy ] || exit 0 [ -n "$payload" ] || exit 0 title=$(json_str prompt) # Without jq the title is still JSON at this point, so a newline is the two # characters \ and n rather than an actual line break. `head` below would not # split on it and the whole prompt would arrive as one long title with \n # sitting in it. Harmless on the jq path, which has already unescaped them. title=${title%%\\n*} # First line only, and no control characters: an OSC string ends at the first # BEL or ESC, so anything of that sort in a prompt would truncate the sequence # and leave the rest to be printed as garbage in the pane. title=$(printf '%s' "$title" | head -n 1 | tr -d '[:cntrl:]' | cut -c1-72) [ -n "$title" ] && printf '\033]0;%s\007' "$title" > "$tty_target" 2>/dev/null exit 0