#!/usr/bin/env bash # Claude Code -> hyprpanel workspace-icon state bridge. # # Invoked from ~/.claude/settings.json hooks. Hook events map to a # state token written to /tmp/hyprpanel-claude/: # # UserPromptSubmit -> active (user just submitted a prompt) # PreToolUse -> active (a tool started; counter++) # PostToolUse -> active (counter--; stays active if >0) # Notification -> waiting (claude wants input/permission) # but suppressed when a tool is in # flight or state is `scheduled` — # the agentPushNotifEnabled idle # ping is too eager and flips # legitimate states to "waiting" # Stop -> done OR `scheduled` if the last # assistant turn used ScheduleWakeup # / CronCreate (claude isn't # finished, it's sleeping until a # /loop wakeup fires) # SessionEnd -> (clear) state and counter both removed # # Sidecar files used by the counter and transcript inspection: # /tmp/hyprpanel-claude/..tools — tool-in-flight counter # (incremented in PreToolUse, # decremented in PostToolUse, # clamped at 0) # # The state directory is monitored by hyprpanel's ClaudeStateService; # the workspaces module tints each kitty's app icon by state. # # Mapping claude -> kitty: walks the parent-pid chain from $$ until # it finds a pid registered as a Hyprland client (the kitty process). # Chain: this script -> claude (node) -> shell -> kitty. set -euo pipefail EVENT="${1:-}" STATE_DIR=/tmp/hyprpanel-claude mkdir -p "$STATE_DIR" # Capture claude's JSON for hooks that need it (Stop reads # transcript_path). Keep stdin drained so claude doesn't stall. STDIN_JSON=$(cat 2>/dev/null || echo '{}') # Walk PPid chain to find an ancestor pid that owns a Hyprland # client (kitty). Cap iterations to avoid runaway loops. resolve_addr() { local pid=$$ local addr ppid for _ in $(seq 1 20); do ppid=$(awk '/^PPid:/ {print $2; exit}' "/proc/$pid/status" 2>/dev/null || true) [ -z "$ppid" ] || [ "$ppid" = "0" ] && break pid=$ppid addr=$(hyprctl -j clients 2>/dev/null \ | jq -r --argjson p "$pid" \ '.[] | select(.pid==$p) | .address' \ | head -n1) addr=${addr#0x} [ -n "$addr" ] && { echo "$addr"; return; } done } ADDR=$(resolve_addr) [ -z "$ADDR" ] && exit 0 STATE_FILE="$STATE_DIR/$ADDR" TOOLS_FILE="$STATE_DIR/.$ADDR.tools" # Atomic write so ClaudeStateService's file monitor never sees a # half-written file. write_state() { local s=$1 local tmp tmp=$(mktemp "$STATE_DIR/.tmp.XXXXXX") printf '%s\n' "$s" >"$tmp" mv -f "$tmp" "$STATE_FILE" } # Touch the .ping sidecar so the renderer plays the blink-decay # animation. Used for any state worth nudging the user about — done # (sticky idle ping), waiting (just appeared), scheduled (just # parked or re-parked). The sidecar's mtime is the trigger; the # renderer fires the animation whenever its tracked mtime advances, # even when the state itself didn't change. touch_ping() { : >"$STATE_DIR/.$ADDR.ping" # ensure exists touch "$STATE_DIR/.$ADDR.ping" } read_tools() { [ -f "$TOOLS_FILE" ] && cat "$TOOLS_FILE" 2>/dev/null || echo 0 } # flock keeps PreToolUse / PostToolUse safe against parallel tool # calls (claude can run multiple tools concurrently). bump_tools() { local delta=$1 ( flock -x 9 local cur new cur=$([ -f "$TOOLS_FILE" ] && cat "$TOOLS_FILE" 2>/dev/null || echo 0) new=$((cur + delta)) [ "$new" -lt 0 ] && new=0 printf '%d\n' "$new" >"$TOOLS_FILE" ) 9>"$TOOLS_FILE.lock" } read_state() { [ -f "$STATE_FILE" ] && cat "$STATE_FILE" 2>/dev/null || echo '' } # Inspect the most recent assistant turn in the transcript for a # ScheduleWakeup / CronCreate tool call. Used by Stop to distinguish # "claude is sleeping for a /loop wakeup" from "claude is genuinely # done" — both look identical from the Stop hook's perspective but # need different visual treatment in the bar. last_turn_has_schedule() { local transcript transcript=$(printf '%s' "$STDIN_JSON" \ | jq -r '.transcript_path // empty' 2>/dev/null) [ -z "$transcript" ] || [ ! -f "$transcript" ] && return 1 # Tail the file; transcripts are append-only JSONL. The last # assistant entry is what Stop just emitted. We scan its # content for a tool_use whose name is a scheduling call. tail -n 50 "$transcript" 2>/dev/null \ | jq -s ' map(select(.type=="assistant" or .role=="assistant")) | last | .message.content // .content // [] | map(select(.type=="tool_use")) | map(.name) | any(. == "ScheduleWakeup" or . == "CronCreate") ' 2>/dev/null \ | grep -q '^true$' } case "$EVENT" in UserPromptSubmit) # Reset the counter — the user is starting a new turn, any # stale tool-in-flight count from a crashed prior turn # shouldn't keep suppressing future notifications. rm -f "$TOOLS_FILE" write_state active ;; PreToolUse) bump_tools 1 write_state active ;; PostToolUse) bump_tools -1 # Recover state to `active` so a permission-prompt cycle # transitions cleanly waiting → active when the user # answers and the tool runs. Without this write, the # kitty would stay orange (waiting) after the permission # was granted until Stop fires. Brief flap into the next # PreToolUse is fine — the icon visibly tracks activity. write_state active ;; Notification) # The Notification hook fires for two distinct reasons: # (1) a real "claude needs you" event — most importantly # the permission prompt (sensitive file, dangerous # command, etc.) which fires *during* a PreToolUse, # with the tool counter still > 0. # (2) the agentPushNotifEnabled "remember me" idle ping # that fires some seconds after Stop while no user # activity happens. # Earlier we suppressed (1) along with (2) when counter>0, # which left kittys blue (active) during permission # prompts — wrong: those need orange. Keep the suppression # narrowly scoped to states where a flip would mislead # rather than to tool-in-flight in general. cur=$(read_state) if [ "$cur" = "scheduled" ]; then # Claude is parked on a /loop ScheduleWakeup. The # agent-idle ping is just "remember me" noise — it's # not user input, the wakeup will resume on its own. exit 0 fi if [ "$cur" = "done" ]; then # Sticky done: the turn really IS finished, the user # just hasn't acknowledged. Don't downgrade the green # check to an orange dot. Touch the ping sidecar so # the renderer plays the blink-decay animation in place # of the state change. touch_ping exit 0 fi write_state waiting # Animate on entry to (or re-entry into) `waiting` so the # transition is hard to miss in peripheral vision. touch_ping ;; Stop) if last_turn_has_schedule; then write_state scheduled else write_state done fi # Animate on every Stop — including back-to-back Stops where # the state was already `scheduled`/`done`. This way a /loop # session that wakes, runs, and re-parks blinks each tick. touch_ping ;; SessionEnd) rm -f "$STATE_FILE" "$TOOLS_FILE" "$TOOLS_FILE.lock" \ "$STATE_DIR/.$ADDR.ping" ;; *) exit 0 ;; esac