#!/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 — straight to # /dev/tty, and on `busy` the prompt is written as an OSC 0 title alongside it. # # Why /dev/tty and not a socket. A hook runs as a child of Claude Code, so its # controlling terminal is the pty of the pane Claude is running in. Writing # there means the bytes 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} # No controlling terminal — running headless, in CI, or under a harness that # detached us. Nothing to report to, and a hook must never be the thing that # breaks a session, so leave quietly. [ -w /dev/tty ] || 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 /dev/tty 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" > /dev/tty 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 payload=$(cat 2>/dev/null) || exit 0 [ -n "$payload" ] || exit 0 if command -v jq >/dev/null 2>&1; then title=$(printf '%s' "$payload" | jq -r '.prompt // ""' 2>/dev/null) || title="" else # No jq — pull the field out directly. This gives up on a prompt whose first # line contains an escaped quote, which is the common case handled badly # rather than the rare case handled wrongly: a missed title costs nothing # because the tab just keeps the name it already had. title=$(printf '%s' "$payload" \ | sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') # 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. title=${title%%\\n*} fi # 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" > /dev/tty 2>/dev/null exit 0