Show Claude Code session status in the GNOME panel

Answers one question at a glance: is any session waiting for me, and
which one. With several sessions open the cost is not knowing what each
is doing, it is noticing that one stopped an hour ago.

Claude Code hooks write one JSON file per session under
~/.local/state/claude-code-status; the extension watches the directory
with Gio.FileMonitor, so nothing polls and there is no daemon.

Two distinctions carry the design:

  * blocked (permission prompt) is kept apart from waiting (turn done).
    Merged, a finished task looks as urgent as a stuck one, which is
    exactly the judgement the indicator exists to make.

  * the panel names the oldest session in the top state, not the latest.
    The session you forget is the one that has been waiting longest.

PostToolUse is registered although it looks redundant: it is the only
event that fires after a permission is granted, so without it a session
stays blocked in the panel for the rest of the turn. It writes only on
an actual state change, so the usual case costs no I/O.

Stop and SessionEnd are synchronous, unlike the rest. Both fire as the
process is about to go quiet, and an async hook racing that exit gets
killed before it writes -- claude -p left a session pinned at busy.
Concurrent hooks for one session serialise on an flock plus a timestamp
guard; tests/test-hook.sh covers each separately, because the burst test
passes on the timestamp guard alone.

Sessions running in zellij are located by tab name rather than by path,
matched through dump-layout on the working directory. The dump carries
no pane ids, so ZELLIJ_PANE_ID cannot be used; rows that do not resolve
stay inert instead of pretending a click does something.

lib/sessions.js deliberately imports nothing from the shell resource
namespace, which lets the riskiest logic -- liveness, ordering, partial
reads, monitoring -- run under plain gjs in tests/test-sessions.js.
This commit is contained in:
av
2026-08-09 18:11:27 +03:00
commit 7fc7f63842
16 changed files with 1898 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Exercises the hook's state machine and its behaviour under concurrency.
#
# Run: tests/test-hook.sh
set -u
HOOK="$(cd "$(dirname "$0")/.." && pwd)/hooks/claude-status-hook.py"
export XDG_STATE_HOME="$(mktemp -d)"
DIR="$XDG_STATE_HOME/claude-code-status"
SID="test-session"
FILE="$DIR/$SID.json"
failures=0
check() { # name expected actual
if [ "$2" = "$3" ]; then
echo "ok $1"
else
echo "FAIL $1 (expected '$2', got '$3')"
failures=$((failures + 1))
fi
}
emit() { # json
echo "$1" | "$HOOK"
}
field() { # key
python3 -c "import json,sys;print(json.load(open('$FILE')).get('$1',''))" 2>/dev/null
}
ev() { # event [extra json]
echo "{\"session_id\":\"$SID\",\"hook_event_name\":\"$1\",\"cwd\":\"/tmp/proj\"${2:+,$2}}"
}
# --- state machine ---------------------------------------------------------
emit "$(ev SessionStart '"source":"startup"')"
check "SessionStart -> idle" "idle" "$(field state)"
emit "$(ev UserPromptSubmit '"prompt":"hi"')"
check "UserPromptSubmit -> busy" "busy" "$(field state)"
before=$(field event_ts)
emit "$(ev PostToolUse '"tool_name":"Bash"')"
check "PostToolUse while busy does not rewrite" "$before" "$(field event_ts)"
emit "$(ev Notification '"notification_type":"permission_prompt","message":"run rm -rf"')"
check "permission_prompt -> blocked" "blocked" "$(field state)"
check "permission message kept" "run rm -rf" "$(field message)"
emit "$(ev PostToolUse '"tool_name":"Bash"')"
check "PostToolUse clears blocked" "busy" "$(field state)"
emit "$(ev Notification '"notification_type":"auth_success","message":"logged in"')"
check "auth_success ignored" "busy" "$(field state)"
emit "$(ev Stop)"
check "Stop -> waiting" "waiting" "$(field state)"
before=$(field event_ts)
emit "$(ev Notification '"notification_type":"idle_prompt","message":"waiting for input"')"
check "idle_prompt while waiting does not rewrite" "$before" "$(field event_ts)"
emit "$(ev Stop '"agent_id":"sub-1"')"
check "subagent Stop ignored" "waiting" "$(field state)"
since_before=$(field since)
emit "$(ev SessionStart '"source":"compact"')"
check "compaction does not reset a live session" "waiting" "$(field state)"
check "compaction does not reset the clock" "$since_before" "$(field since)"
# --- concurrency -----------------------------------------------------------
# The hazard: the last PostToolUse of a turn is async and can still be running
# when the turn's Stop fires. Two mechanisms defend against it and they are
# tested separately, because the burst below passes on the timestamp guard
# alone -- it does not prove the lock is doing anything.
emit "$(ev UserPromptSubmit '"prompt":"go"')"
for _ in $(seq 30); do
emit "$(ev PostToolUse '"tool_name":"Bash"')" &
done
sleep 0.3 # let the racers start, so Stop's timestamp is genuinely later
emit "$(ev Stop)"
wait
check "Stop survives a burst of concurrent PostToolUse" "waiting" "$(field state)"
python3 -c "import json;json.load(open('$FILE'))" 2>/dev/null
check "state file is still valid JSON" "0" "$?"
# The lock itself: what the timestamp guard cannot cover is one hook reading the
# old state, being descheduled while another writes, then writing its own stale
# decision on top. Holding the lock from outside makes that window observable --
# a hook must wait for it rather than read-and-write straight through.
emit "$(ev UserPromptSubmit '"prompt":"go"')"
python3 -c "
import fcntl, time
with open('$FILE.lock', 'w') as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
time.sleep(1.5)
" &
holder=$!
sleep 0.4
emit "$(ev Stop)" &
sleep 0.5
check "hook blocks while the lock is held" "busy" "$(field state)"
wait $holder
wait
check "hook proceeds once the lock is released" "waiting" "$(field state)"
# --- teardown --------------------------------------------------------------
emit "$(ev SessionEnd '"reason":"other"')"
[ -e "$FILE" ]; check "SessionEnd removes the state file" "1" "$?"
[ -e "$FILE.lock" ]; check "SessionEnd removes the lock file" "1" "$?"
rm -rf "$XDG_STATE_HOME"
if [ "$failures" -gt 0 ]; then
echo; echo "$failures failure(s)"; exit 1
fi
echo; echo "all passed"