Add hook system.
This commit is contained in:
Executable
+77
@@ -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
|
||||
Reference in New Issue
Block a user