← back to Terminal Status
verification/install/before/.claude/skills/color/scripts/working-state.sh
220 lines
#!/usr/bin/env bash
# working-state.sh — "Claude is working" indicator in the iTerm2 window HEADER.
#
# on (UserPromptSubmit hook) prepend a green-square bar to the header title
# and start a watcher that GROWS the bar and
# HEATS it (🟩 → 🟨 → 🟧 → 🟥) the longer the
# turn runs.
# off (Stop hook) stop the watcher, restore the plain topic title
#
# Why the header TITLE TEXT (not tab tint / OSC 9;4 progress): tested live on
# iTerm2 3.6.10 — it ignores the OSC 9;4 progress bar AND won't background-tint
# the title bar on single-tab windows. But the title TEXT always renders in the
# header. So the indicator is a row of colored squares prepended to the title:
# a growing, heating "progress bar + icon" that's visible no matter the layout.
#
# Bash tool has no controlling tty, but HOOKS run in the terminal's context, so
# the ancestry walk (same as color-title.sh) resolves the pty here.
set -u
# --- tunables: elapsed seconds → square color + count -----------------------
T_YELLOW=60 # after this many seconds the bar turns yellow
T_ORANGE=120 # then orange
T_RED=180 # then red
RAMP_STEP=3 # re-paint cadence (s) while working
IDLE_STEP="${IDLE_BLINK_SEC:-0.7}" # blink cadence (s) while idle/waiting
MODE="${1:-}"
# Streamline / lite mode — one flag makes the working-state indicator no-op
# fleet-wide (heavy load, autonomous officer-yolo loops, headless jobs). This
# skips the per-hook pty resolution (ps eww over every claude proc) + the
# repaint watcher loop — the O(sessions^2) cost that bogs down under many
# parallel sessions. Existing watchers self-terminate via their owner_pid check.
# Reversible: rm ~/.claude/hooks-lite. Internal watcher modes are exempt so a
# running loop can still be told to exit cleanly.
case "$MODE" in _watch|_idle_watch) : ;; *) [ -f "$HOME/.claude/hooks-lite" ] && exit 0 ;; esac
TITLE_DIR="${HOME}/.claude/color-session-titles"
RUN_DIR="${HOME}/.claude/working-state"
mkdir -p "$RUN_DIR"
# bar string for a given elapsed time: heat + growing count
bar_for() { # echoes the square bar
local e="$1" sq n i out=""
if (( e < T_YELLOW )); then sq="🟩"; n=$(( e/20 + 1 )); (( n>3 )) && n=3
elif (( e < T_ORANGE )); then sq="🟨"; n=4
elif (( e < T_RED )); then sq="🟧"; n=5
else sq="🟥"; n=6
fi
for ((i=0;i<n;i++)); do out+="$sq"; done
printf '%s' "$out"
}
# ---------------------------------------------------------------------------
# internal watcher: _watch <tty> <inside_tmux> <session_id>
# re-reads the topic file each tick so it picks up color-title.sh's title
# ---------------------------------------------------------------------------
if [[ "$MODE" == "_watch" ]]; then
tty_path="$2"; inside_tmux="$3"; sid="$4"; owner_pid="${5:-}"
set_title() {
if (( inside_tmux )); then
printf '\033Ptmux;\033\033]1;%s\007\033\\' "$1" > "$tty_path" 2>/dev/null
printf '\033Ptmux;\033\033]2;%s\007\033\\' "$1" > "$tty_path" 2>/dev/null
else
printf '\033]1;%s\007' "$1" > "$tty_path" 2>/dev/null
printf '\033]2;%s\007' "$1" > "$tty_path" 2>/dev/null
fi
}
trap 'exit 0' TERM INT
elapsed=0
while :; do
# stop the moment our session dies or the pty is gone — never paint a
# REUSED tty (the leaked-watcher prompt-corruption bug).
[[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" 2>/dev/null && exit 0
[[ -w "$tty_path" ]] || exit 0
topic=""; [[ -f "${TITLE_DIR}/${sid}" ]] && topic="$(cat "${TITLE_DIR}/${sid}" 2>/dev/null)"
bar="$(bar_for "$elapsed")"
if [[ -n "$topic" ]]; then set_title "${bar} ${topic}"; else set_title "${bar} working…"; fi
sleep "$RAMP_STEP"
elapsed=$(( elapsed + RAMP_STEP ))
done
fi
# ---------------------------------------------------------------------------
# internal idle watcher: _idle_watch <tty> <inside_tmux> <session_id>
# Blinks PURPLE squares in the title while the turn is handed back to Steve.
# Pulses 🟪🟪🟪 <-> 🟪 so there is ALWAYS at least one purple dot — it blinks
# but never goes blank. Re-reads the topic each tick like the working watcher.
# ---------------------------------------------------------------------------
if [[ "$MODE" == "_idle_watch" ]]; then
tty_path="$2"; inside_tmux="$3"; sid="$4"; owner_pid="${5:-}"
set_title() {
if (( inside_tmux )); then
printf '\033Ptmux;\033\033]1;%s\007\033\\' "$1" > "$tty_path" 2>/dev/null
printf '\033Ptmux;\033\033]2;%s\007\033\\' "$1" > "$tty_path" 2>/dev/null
else
printf '\033]1;%s\007' "$1" > "$tty_path" 2>/dev/null
printf '\033]2;%s\007' "$1" > "$tty_path" 2>/dev/null
fi
}
trap 'exit 0' TERM INT
phase=0
while :; do
# stop the moment our session dies or the pty is gone — never paint a
# REUSED tty (the leaked-watcher prompt-corruption bug).
[[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" 2>/dev/null && exit 0
[[ -w "$tty_path" ]] || exit 0
topic=""; [[ -f "${TITLE_DIR}/${sid}" ]] && topic="$(cat "${TITLE_DIR}/${sid}" 2>/dev/null)"
if (( phase == 0 )); then bar="🟪🟪🟪"; else bar="🟪"; fi
if [[ -n "$topic" ]]; then set_title "${bar} ${topic}"; else set_title "${bar} idle…"; fi
phase=$(( 1 - phase ))
sleep "$IDLE_STEP"
done
fi
# --- main: resolve session + pty -------------------------------------------
input="$(cat 2>/dev/null || true)"
session_id="$(printf '%s' "$input" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
[[ -z "$session_id" ]] && session_id="default"
pidfile="${RUN_DIR}/${session_id}.watcher.pid"
# Resolve THIS session's OWN tab pty.
# PRIMARY: match the stable w#t#p# slot prefix of ITERM_SESSION_ID against each
# live `claude` process's env. The hook inherits ITERM_SESSION_ID from its claude
# parent, so this pins the correct tab even across many parallel sessions.
# The old ancestry walk landed on a NEIGHBOR tab (every session painted someone
# else's tab) — kept only as a fallback when there's no ITERM_SESSION_ID.
tty_path=""
owner_pid=""
if [[ -n "${ITERM_SESSION_ID:-}" ]]; then
_slot="${ITERM_SESSION_ID%%:*}"
for _cpid in $(pgrep -x claude 2>/dev/null); do
_csid="$(ps eww -p "$_cpid" 2>/dev/null | grep -o 'ITERM_SESSION_ID=[^ ]*' | head -1 | cut -d= -f2-)"
if [[ "${_csid%%:*}" == "$_slot" ]]; then
_ctty="$(ps -o tty= -p "$_cpid" 2>/dev/null | tr -d ' ')"
if [[ "$_ctty" == ttys* ]]; then tty_path="/dev/$_ctty"; owner_pid="$_cpid"; fi
fi
done
fi
if [[ -z "$tty_path" ]]; then
p=$$
for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do
line="$(ps -o ppid=,tty= -p "$p" 2>/dev/null)" || break
# shellcheck disable=SC2086
set -- $line; pp="${1:-}"; tt="${2:-}"
[[ -z "$pp" ]] && break
if [[ "$tt" == ttys* ]]; then tty_path="/dev/$tt"; break; fi
p="$pp"
done
fi
[[ -n "$tty_path" && -w "$tty_path" ]] || exit 0
inside_tmux=0
[[ -n "${TMUX:-}" ]] && inside_tmux=1
set_title() {
if (( inside_tmux )); then
printf '\033Ptmux;\033\033]1;%s\007\033\\' "$1" > "$tty_path" 2>/dev/null
printf '\033Ptmux;\033\033]2;%s\007\033\\' "$1" > "$tty_path" 2>/dev/null
else
printf '\033]1;%s\007' "$1" > "$tty_path" 2>/dev/null
printf '\033]2;%s\007' "$1" > "$tty_path" 2>/dev/null
fi
}
stop_watcher() {
[[ -f "$pidfile" ]] || return 0
kill "$(cat "$pidfile" 2>/dev/null)" 2>/dev/null || true
rm -f "$pidfile" "${pidfile}.owner"
}
# Reap watchers left behind by DEAD sessions: any pidfile whose recorded OWNER
# claude PID is gone gets its watcher killed + files removed. Clears leaked
# painters that survive a crash/kill-9 (no clean Stop hook) and would otherwise
# keep writing OSC title escapes to a REUSED pty. Only reaps when the owner is
# recorded AND confirmed dead — never guesses at unknown-owner (fallback-path)
# watchers, which self-terminate via their own per-tick tty-writable guard.
reap_orphans() {
local pf owner wpid
for pf in "${RUN_DIR}"/*.watcher.pid; do
[[ -e "$pf" ]] || continue
[[ "$pf" == "$pidfile" ]] && continue
owner=""; [[ -f "${pf}.owner" ]] && owner="$(cat "${pf}.owner" 2>/dev/null)"
[[ -z "$owner" ]] && continue
kill -0 "$owner" 2>/dev/null && continue # owner alive → legit, leave it
wpid="$(cat "$pf" 2>/dev/null)"
[[ -n "$wpid" ]] && kill "$wpid" 2>/dev/null || true
rm -f "$pf" "${pf}.owner"
done
}
topic=""; [[ -f "${TITLE_DIR}/${session_id}" ]] && topic="$(cat "${TITLE_DIR}/${session_id}" 2>/dev/null)"
case "$MODE" in
on)
stop_watcher
reap_orphans
set_title "🟩 ${topic:-working…}"
( "$0" _watch "$tty_path" "$inside_tmux" "$session_id" "$owner_pid" ) >/dev/null 2>&1 &
echo $! > "$pidfile"
[[ -n "$owner_pid" ]] && echo "$owner_pid" > "${pidfile}.owner"
disown 2>/dev/null || true
;;
off)
stop_watcher
reap_orphans
# idle/waiting: blink PURPLE dots in the title (never plain) until next turn
set_title "🟪🟪🟪 ${topic:-idle…}"
( "$0" _idle_watch "$tty_path" "$inside_tmux" "$session_id" "$owner_pid" ) >/dev/null 2>&1 &
echo $! > "$pidfile"
[[ -n "$owner_pid" ]] && echo "$owner_pid" > "${pidfile}.owner"
disown 2>/dev/null || true
;;
*)
echo "usage: working-state.sh {on|off}" >&2
exit 2
;;
esac
exit 0