Add hook system.

This commit is contained in:
Greyson Parrelli
2026-08-11 16:24:53 -04:00
parent 73a0187db5
commit dd60e52005
9 changed files with 788 additions and 3 deletions
+77
View File
@@ -0,0 +1,77 @@
#!/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