From 302df34f099b2fb50ef3ef83f0c5533b63991498 Mon Sep 17 00:00:00 2001 From: Oleks Date: Sun, 10 May 2026 15:35:30 +0300 Subject: [PATCH] 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/
. 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). --- .claude-plugin/hooks.json | 64 +++++++++++ .claude-plugin/plugin.json | 19 ++++ .gitignore | 1 + CLAUDE.md | 48 +++++++++ README.md | 62 +++++++++++ hooks/hyprpanel-state.sh | 212 +++++++++++++++++++++++++++++++++++++ 6 files changed, 406 insertions(+) create mode 100644 .claude-plugin/hooks.json create mode 100644 .claude-plugin/plugin.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100755 hooks/hyprpanel-state.sh diff --git a/.claude-plugin/hooks.json b/.claude-plugin/hooks.json new file mode 100644 index 0000000..5a61b85 --- /dev/null +++ b/.claude-plugin/hooks.json @@ -0,0 +1,64 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/hyprpanel-state.sh UserPromptSubmit" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/hyprpanel-state.sh PreToolUse" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/hyprpanel-state.sh PostToolUse" + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/hyprpanel-state.sh Notification" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/hyprpanel-state.sh Stop" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/hyprpanel-state.sh SessionEnd" + } + ] + } + ] + } +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..0399548 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "hyprpanel-state", + "version": "1.0.0", + "description": "Surfaces per-window Claude Code state in the Hyprland workspaces bar (HyprPanel). Wires hooks for UserPromptSubmit / PreToolUse / PostToolUse / Notification / Stop / SessionEnd that write state tokens to /tmp/hyprpanel-claude/ for the bar to read. Each kitty's icon and pip indicator reflects whether claude is active (blue), waiting on input (orange ●), done (green ✔), or sleeping for a /loop ScheduleWakeup (cyan ⧗). Companion to the workspaces patch in projects/hyprpanel-dev — installed bar binary needs that patch for the visuals; this plugin only owns the hook side.", + "author": { + "name": "oleks", + "email": "plugins@oleks.space" + }, + "license": "MIT", + "keywords": [ + "hyprland", + "hyprpanel", + "workspaces", + "hooks", + "claude-state", + "desktop-integration", + "linux" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c40552a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +Guidance for Claude Code when working in this repository. + +## What This Is + +A Claude Code plugin (no skill — this one is hooks-only) that bridges +Claude Code lifecycle events to a state file the HyprPanel workspaces bar +reads, so each kitty's icon reflects what claude is doing in that +terminal. + +## Structure + +``` +.claude-plugin/plugin.json # Plugin manifest +.claude-plugin/hooks.json # Hook event → script mapping +hooks/hyprpanel-state.sh # The script: PPid walk + state file writes +README.md # User-facing docs +``` + +## Bar Side + +This plugin does NOT own the visual — that lives in `~/projects/hyprpanel-dev` +(the patched HyprPanel). The contract between the two is the +`/tmp/hyprpanel-claude/` directory layout (state files, tools counter, +ping sidecar). Changes to the protocol must update both sides. + +## Editing Guidelines + +- Bump `version` in `plugin.json` for any change Claude Code should pick + up (it caches by version). +- Hook script path uses `${CLAUDE_PLUGIN_ROOT}` so it works wherever the + plugin gets installed. +- Keep the script idempotent: hooks may fire concurrently for parallel + tool calls; the `.tools` counter is flock-protected. +- The PPid-walk address resolution depends on a chain of + `script → claude → shell → kitty`. Don't break that by spawning the + script through extra wrappers. + +## Why a Plugin (Not Dotfile-Managed) + +- Reproducible: install via `claude plugin add`, no manual scp / nixos + rebuild loop for the script itself. +- Versioned: bumping `plugin.json.version` lets Claude Code re-cache + cleanly across hosts. +- Decoupled from emmett's NixOS config: the bar visuals are baked in via + patch (since they're hyprpanel internals), but the hook side is the + user's claude config and benefits from plugin lifecycle. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6343e4 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# hyprpanel-state + +Claude Code plugin that surfaces per-window claude state in the HyprPanel +workspaces bar. + +## What it does + +Wires Claude Code hooks (UserPromptSubmit / PreToolUse / PostToolUse / +Notification / Stop / SessionEnd) to a shell script that walks the parent +PID chain from itself up through claude → shell → kitty until it finds a +PID owned by a Hyprland kitty client, then writes a state token to +`/tmp/hyprpanel-claude/
`. The patched HyprPanel bar reads those +tokens and tints each kitty's app icon by what claude is doing. + +| State | Color (in bar) | Pip indicator | Triggered by | +| ----------- | -------------- | ------------- | ------------------------------------------------- | +| `active` | blue | `●` | `UserPromptSubmit` / `PreToolUse` / `PostToolUse` | +| `waiting` | orange | `●` | `Notification` (suppressed when state is done/scheduled) | +| `done` | green | `✔` | `Stop` (no `ScheduleWakeup` in last assistant turn) | +| `scheduled` | cyan | `⧗` | `Stop` (last turn used `ScheduleWakeup` / `CronCreate`) | +| (cleared) | default | — | `SessionEnd` | + +A blink-decay opacity animation fires on (re-)assignment of waiting, +scheduled, done — and on the agent-idle "remember me" Notification while +already done. Sticky-done means the green check survives idle pings. + +## Sidecar files + +- `/tmp/hyprpanel-claude/` — current state token +- `/tmp/hyprpanel-claude/..tools` — tool-in-flight counter + (incremented on PreToolUse, decremented on PostToolUse, flock-protected + for parallel tool fan-outs) +- `/tmp/hyprpanel-claude/..ping` — touch-only sidecar; mtime advance + triggers the bar's blink animation + +## Bar side + +The visuals live in HyprPanel and are not owned by this plugin. Install / +patch HyprPanel separately — see `~/projects/hyprpanel-dev/PROMPT.md` for +the workspaces module changes that read these state files. + +## Dependencies + +- `hyprctl` (Hyprland) +- `jq` +- `flock` + +All standard on a NixOS desktop. + +## Install + +```bash +claude plugin add https://claude-plugins.oleks.space/plugins/hyprpanel-state.git +``` + +Once enabled, the hook chain wires itself up — no `~/.claude/settings.json` +edits required. Claude sessions started after the install will fire hooks +into the script; pre-existing sessions need to restart to pick them up. + +## License + +MIT diff --git a/hooks/hyprpanel-state.sh b/hooks/hyprpanel-state.sh new file mode 100755 index 0000000..0bbd59c --- /dev/null +++ b/hooks/hyprpanel-state.sh @@ -0,0 +1,212 @@ +#!/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