diff --git a/hooks/hyprpanel-state.sh b/hooks/hyprpanel-state.sh index 3420969..b99e219 100755 --- a/hooks/hyprpanel-state.sh +++ b/hooks/hyprpanel-state.sh @@ -42,12 +42,9 @@ mkdir -p "$STATE_DIR" # transcript_path). Keep stdin drained so claude doesn't stall. STDIN_JSON=$(cat 2>/dev/null || echo '{}') -# session_id from the hook payload — stable per session. Used to -# cache the resolved address (resolution is the expensive step) and, -# under the daemon, to locate the per-session rendezvous socket. +# session_id from the hook payload — stable per session, used to map +# the session to its kitty window under the daemon. SID=$(printf '%s' "$STDIN_JSON" | jq -r '.session_id // empty' 2>/dev/null || true) -CACHE_FILE="" -[ -n "$SID" ] && CACHE_FILE="$STATE_DIR/.session-$SID.addr" # Walk a PPid chain upward from $1 until an ancestor pid owns a # Hyprland client (the kitty window). Prints the bare (0x-stripped) @@ -69,54 +66,56 @@ addr_from_ppid_chain() { return 1 } -# Daemon mode: the session runs in a pre-warmed spare process with no -# terminal in its process tree or environment — KITTY_* are unset and -# the PPid chain leads to the daemon -> systemd, never to kitty. The -# daemon brokers all I/O (every rv/pty socket peers to the daemon, not -# to the front-end; it writes no session metadata to disk and the -# transcript is not held open), so there is NO traversable link from -# the hook back to the originating window. The only signal available -# from inside the session is Hyprland's global focus. -# -# We exploit timing: the FIRST hook of a session is UserPromptSubmit, -# which fires the instant you submit your first prompt — while the -# kitty window you typed in is still focused. Capture the focused -# window then and cache it (resolve_addr caches by session id, so -# every later hook reuses this even after focus moves). Guarded to -# kitty windows so a non-terminal focus never gets mispainted. -addr_active_window() { - local j class addr - j=$(hyprctl -j activewindow 2>/dev/null || true) - class=$(printf '%s' "$j" | jq -r '.class // empty' 2>/dev/null || true) - case "$class" in - *kitty*|*Kitty*) : ;; - *) return 1 ;; - esac - addr=$(printf '%s' "$j" | jq -r '.address // empty' 2>/dev/null || true) +# Daemon mode: the session runs in a pre-warmed spare with no terminal +# in its process tree or environment (KITTY_* unset, PPid chain leads +# to the daemon -> systemd). The daemon brokers all I/O, so there is +# no socket/env/PPid path back to the originating window. But it does +# launch each session as a NAMED background job, and the viewer writes +# that name into the kitty window's title. So map session_id -> job +# name -> the kitty window whose title contains it. This is focus- +# independent, so it works under fleet view where the session's window +# is never focused. +session_name() { + local sid=$1 name="" + local jobf="$HOME/.claude/jobs/${sid%%-*}/state.json" + if [ -f "$jobf" ]; then + name=$(jq -r --arg s "$sid" \ + 'select(.sessionId==$s) | .name // empty' "$jobf" 2>/dev/null || true) + fi + if [ -z "$name" ]; then + name=$(jq -rs --arg s "$sid" \ + 'map(select(.sessionId==$s)) | .[0].name // empty' \ + "$HOME"/.claude/sessions/*.json 2>/dev/null || true) + fi + printf '%s' "$name" +} + +addr_via_session_name() { + local sid=$1 + [ -n "$sid" ] || return 1 + local name + name=$(session_name "$sid") + [ -n "$name" ] || return 1 + local addr + addr=$(hyprctl -j clients 2>/dev/null \ + | jq -r --arg n "$name" \ + '.[] | select(((.class // "")|ascii_downcase|contains("kitty")) + and ((.title // "")|contains($n))) | .address' \ + | head -n1) addr=${addr#0x} [ -n "$addr" ] || return 1 printf '%s' "$addr" } -# Resolve once, cache by session id, reuse for every later hook. +# Resolve fresh on every hook (cheap; no caching). Name-matching is +# deterministic and tracks the session even if its window moves. resolve_addr() { - if [ -n "$CACHE_FILE" ] && [ -s "$CACHE_FILE" ]; then - cat "$CACHE_FILE" - return 0 - fi local addr="" addr=$(addr_from_ppid_chain $$) || addr="" # non-daemon: real chain - # Daemon fallback uses the focused window, so it's only meaningful - # for a session that actually owns a terminal. A background job - # (CLAUDE_JOB_DIR set) has no window of its own — letting it grab - # the focused window paints a random unrelated kitty. Skip it. - if [ -z "$addr" ] && [ -z "${CLAUDE_JOB_DIR:-}" ]; then - addr=$(addr_active_window) || addr="" # daemon: focus - fi - if [ -z "$addr" ]; then return 1; fi - if [ -n "$CACHE_FILE" ]; then - printf '%s\n' "$addr" >"$CACHE_FILE" 2>/dev/null || true + if [ -z "$addr" ]; then + addr=$(addr_via_session_name "$SID") || addr="" # daemon: name->title fi + [ -n "$addr" ] || return 1 printf '%s' "$addr" } @@ -264,7 +263,7 @@ case "$EVENT" in ;; SessionEnd) rm -f "$STATE_FILE" "$TOOLS_FILE" "$TOOLS_FILE.lock" \ - "$STATE_DIR/.$ADDR.ping" ${CACHE_FILE:+"$CACHE_FILE"} + "$STATE_DIR/.$ADDR.ping" ;; *) exit 0