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).
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/<addr> 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.claude/
|
||||||
@@ -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.
|
||||||
@@ -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/<address>`. 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/<addr>` — current state token
|
||||||
|
- `/tmp/hyprpanel-claude/.<addr>.tools` — tool-in-flight counter
|
||||||
|
(incremented on PreToolUse, decremented on PostToolUse, flock-protected
|
||||||
|
for parallel tool fan-outs)
|
||||||
|
- `/tmp/hyprpanel-claude/.<addr>.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
|
||||||
Executable
+212
@@ -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/<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
|
||||||
Reference in New Issue
Block a user