188 lines
7.3 KiB
Bash
Executable File
188 lines
7.3 KiB
Bash
Executable File
#!/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
|