Files
claude-code-gnome-extension/hooks/install.py
T
av 7fc7f63842 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.
2026-08-09 18:11:27 +03:00

94 lines
2.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Register (or remove) the status hooks in ~/.claude/settings.json.
Merges into the existing file rather than rewriting it: settings.json holds
unrelated user configuration, and entries for other tools must survive both
install and uninstall. Ownership is tracked by the command path, so a repo
moved to a new location cleanly replaces its old registration.
Usage: install.py [--uninstall] [--settings PATH]
"""
import json
import os
import shutil
import sys
HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-hook.py")
# Stop and SessionEnd are deliberately synchronous. Both fire as claude is
# about to go quiet or exit, and an async child racing that exit gets killed
# before it writes -- observed with `claude -p`, which left a session stuck at
# "busy" forever. They fire at most once per turn, so ~20 ms is free; the
# per-tool-call events stay async so they never sit in the agent's loop.
EVENTS = {
"SessionStart": True,
"UserPromptSubmit": True,
"Notification": True,
"PostToolUse": True,
"PreCompact": True,
"Stop": False,
"SessionEnd": False,
}
def entry(async_):
hook = {"type": "command", "command": HOOK, "timeout": 5}
if async_:
hook["async"] = True
return {"matcher": "", "hooks": [hook]}
def is_ours(group):
return any(
h.get("command", "").endswith("claude-status-hook.py")
for h in group.get("hooks", [])
if isinstance(h, dict)
)
def main():
uninstall = "--uninstall" in sys.argv
path = os.path.expanduser("~/.claude/settings.json")
if "--settings" in sys.argv:
path = sys.argv[sys.argv.index("--settings") + 1]
try:
with open(path) as fh:
settings = json.load(fh)
except FileNotFoundError:
settings = {}
except ValueError as exc:
sys.exit("refusing to touch malformed %s: %s" % (path, exc))
if os.path.exists(path):
shutil.copyfile(path, path + ".bak")
hooks = settings.setdefault("hooks", {})
for event in EVENTS:
groups = [g for g in hooks.get(event, []) if not is_ours(g)]
if not uninstall:
groups.append(entry(EVENTS[event]))
if groups:
hooks[event] = groups
else:
hooks.pop(event, None)
if not hooks:
settings.pop("hooks", None)
# Replaced atomically rather than truncated in place: claude re-reads
# settings.json as it changes, so a running session can be reading this
# exact file, and a truncate-then-stream write hands it invalid JSON.
tmp = "%s.%d.tmp" % (path, os.getpid())
with open(tmp, "w") as fh:
json.dump(settings, fh, indent=2)
fh.write("\n")
os.replace(tmp, path)
print("%s %s in %s" % ("removed" if uninstall else "installed", HOOK, path))
print("restart running claude sessions for the change to take effect")
if __name__ == "__main__":
main()