Files
Oleks 302df34f09 initial: hyprpanel-state plugin
Hooks-only Claude Code plugin that surfaces per-window claude state
in the patched HyprPanel workspaces bar. Wires UserPromptSubmit /
PreToolUse / PostToolUse / Notification / Stop / SessionEnd to a
shell script that walks the parent-PID chain to find the kitty
client whose claude this script lives in, then writes a state token
to /tmp/hyprpanel-claude/<address>.

Bar visuals are owned separately by the workspaces patch in
projects/hyprpanel-dev. The contract between the two is the
/tmp/hyprpanel-claude/ directory layout (state file, .tools
counter, .ping sidecar for blink-decay animations).
2026-05-10 15:35:30 +03:00

213 lines
8.1 KiB
Bash
Executable File

#!/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/<hyprland-address>:
#
# 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/.<addr>.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