← back to Terminal Status

verification/install/before/.claude/skills/colordots/paint-lib.sh

76 lines

#!/usr/bin/env bash
# paint-lib.sh — the ONE safe path for tinting an iTerm2 tab via an OSC write.
#
# Fixes the 2026-09-08 "dot payload lands in the Claude input field" bug. Two proven
# root causes (reproduced in a throwaway iTerm window, not on a live pane):
#
#   1. SUBAGENT CROSS-WRITE. The Claude Bash tool runs with no controlling tty, so the
#      old ancestry walk climbed past the tty-less `zsh -c` shells to the OWNING `claude`
#      process's ttysNNN. From the top-level session that resolves to the right pane, but
#      from a SPAWNED SUBAGENT (Agent tool) it resolves to the PARENT session's pty — so a
#      subagent painting a dot wrote straight into Steve's live pane. A subagent has no tab
#      of its own; it must never paint. (CLAUDE_CODE_CHILD_SESSION=1 marks a subagent.)
#
#   2. BACKGROUND PULSE LOOP. The dot painters started a background loop that rewrote the
#      tab-color OSC every ~0.11s forever. Those rapid async writes interleave with the
#      Claude TUI's own pty output; a torn/desynced sequence drops its leading ESC]6;
#      framing and iTerm renders the tail ("1;bg;green;brightness;0") as literal text on the
#      Claude screen — exactly the reported symptom. A SINGLE atomic write can't be torn.
#
# A clean single OSC write was verified to set the tab color AND to never enter a
# raw-mode reader's stdin — so painting once, atomically, is both correct and leak-free.

TABDOTS_DIR="${HOME}/.claude/tab-dots"

# True (0) iff running inside a spawned subagent — MUST NOT paint (no tab of its own;
# any write lands in the parent session's live pane).
tabdots_is_subagent() { [ -n "${CLAUDE_CODE_CHILD_SESSION:-}" ]; }

# Resolve THIS session's controlling pty by climbing ancestry to the owning `claude`
# process's ttys. Prints "/dev/ttysNNN" on success. Returns non-zero (and prints nothing)
# when called from a subagent or when no ttys is found — callers must treat that as "do not paint".
tabdots_resolve_tty() {
  tabdots_is_subagent && return 1
  local p=$$ line pp tt
  for _ in $(seq 1 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
    case "$tt" in ttys*) printf '/dev/%s' "$tt"; return 0;; esac
    p="$pp"
  done
  return 1
}

# Kill any legacy background pulse loop on a tty (the old corruption engine) and drop its pidfile.
tabdots_stop_pulse() {
  local tp="$1" pf
  pf="${TABDOTS_DIR}/$(basename "$tp").pulse.pid"
  [ -f "$pf" ] && { kill "$(cat "$pf" 2>/dev/null)" 2>/dev/null; rm -f "$pf"; }
  return 0
}

# Paint a tab in ONE atomic write() so a concurrent writer can never tear it.
#   $1 tty_path   $2 R   $3 G   $4 B   $5 title   $6 off(1=reset)
# No background loop — solid color only. Refuses in a subagent context.
tabdots_paint() {
  local tp="$1" R="$2" G="$3" B="$4" title="$5" off="${6:-0}"
  tabdots_is_subagent && return 2
  [ -w "$tp" ] || return 1
  tabdots_stop_pulse "$tp"
  if [ "$off" = "1" ]; then
    # Single write: reset tab color + clear badge.
    printf '\033]6;1;bg;*;default\007\033]1337;SetBadgeFormat=\a' > "$tp" 2>/dev/null
    rm -f "${TABDOTS_DIR}/$(basename "$tp").dot" 2>/dev/null
    return 0
  fi
  local badge; badge="$(printf '%s' "$title" | base64 | tr -d '\n')"
  # ONE write(): 3 tab-color OSCs + both title channels + the in-window badge, concatenated.
  printf '\033]6;1;bg;red;brightness;%d\007\033]6;1;bg;green;brightness;%d\007\033]6;1;bg;blue;brightness;%d\007\033]1;%s\007\033]2;%s\007\033]1337;SetBadgeFormat=%s\a' \
    "$R" "$G" "$B" "$title" "$title" "$badge" > "$tp" 2>/dev/null
  mkdir -p "$TABDOTS_DIR"
  printf '%s' "$title" > "${TABDOTS_DIR}/$(basename "$tp").dot"
  return 0
}