← 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