From 7fc7f638424272bc8ac75584b07d6342c910dd87 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sun, 9 Aug 2026 18:11:27 +0300 Subject: [PATCH] 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. --- .gitignore | 2 + NOTES.md | 123 ++++++ README.md | 130 ++++++ extension.js | 16 + hooks/claude-status-hook.py | 291 +++++++++++++ hooks/install.py | 93 +++++ lib/format.js | 36 ++ lib/indicator.js | 394 ++++++++++++++++++ lib/sessions.js | 235 +++++++++++ lib/zellij.js | 162 +++++++ metadata.json | 10 + prefs.js | 106 +++++ ....extensions.claude-code-status.gschema.xml | 26 ++ stylesheet.css | 48 +++ tests/test-hook.sh | 116 ++++++ tests/test-sessions.js | 110 +++++ 16 files changed, 1898 insertions(+) create mode 100644 .gitignore create mode 100644 NOTES.md create mode 100644 README.md create mode 100644 extension.js create mode 100755 hooks/claude-status-hook.py create mode 100755 hooks/install.py create mode 100644 lib/format.js create mode 100644 lib/indicator.js create mode 100644 lib/sessions.js create mode 100644 lib/zellij.js create mode 100644 metadata.json create mode 100644 prefs.js create mode 100644 schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml create mode 100644 stylesheet.css create mode 100755 tests/test-hook.sh create mode 100644 tests/test-sessions.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58dc2c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +schemas/gschemas.compiled +__pycache__/ diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..f931d23 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,123 @@ +# Индикатор статуса Claude Code в панели GNOME + +Заметка для быстрого старта. Написана до кода — здесь замысел и проверенные +факты об окружении, а не описание существующего. + +## Задача + +Знать, **когда переключиться** на терминал с Claude Code и **на какой именно**, +не глядя в сами терминалы. Состояний три: работает / ждёт меня / тихо. + +Сессий одновременно несколько (разные проекты, разные терминалы) — это основной +режим, а не краевой случай. + +## Решения, принятые заранее + +- **Уведомлений не будет.** `notify-send` отвергнут сознательно, хотя он и был бы + дешевле. Следствие: панель — единственный канал, значит расширение делается + сразу, «сначала уведомления, потом может быть индикатор» отпадает. +- **Панель называет проект, а не только состояние.** «Кто-то ждёт» оставляет + гадать, в каком из четырёх терминалов; знать «когда» без «куда» бесполезно. +- **Сабагенты внутри сессии не показываются.** Батч из восьми задач — это одна + строка «работает 40 мин», и это правильная строка: пятый воркер из восьми ни о + чём не просит. Хук `SubagentStop` для этого существует, но вешать на него + статус — шум. Если понадобится прогресс внутри батча (сделано N из 8), брать + его надо из учёта задач, а не из хуков. + +## Окружение (проверено 2026-08-07) + +- GNOME Shell **46.0**, сессия **Wayland**, `XDG_CURRENT_DESKTOP=ubuntu:GNOME`. +- `~/.claude/settings.json`: ключа `hooks` нет вовсе (`{}`) — место чистое, + ничего не сломаем. `statusLine` занят: `bash ~/.claude/statusline-command.sh`. + +## Шаблон — своё же расширение + +`~/projects/private/sing-box-gnome-extension`, оно же +`sing-box-status@git.vakhrushev.me`. Установлено **симлинком** из репозитория в +`~/.local/share/gnome-shell/extensions/` — так же ставим и это. + +Скелет копируется целиком: + +``` +extension.js 16 строк: enable() → Main.panel.addToStatusArea(uuid, indicator) +lib/indicator.js панель и меню +lib/format.js форматирование +prefs.js настройки +schemas/ gschema.xml + gschemas.compiled +metadata.json "shell-version": ["45","46","47","48"] +stylesheet.css +``` + +Меняется только источник данных: вместо опроса Clash API по HTTP — +**`Gio.FileMonitor`** на каталоге состояния. Это push, поллинг не нужен, выходит +проще оригинала. + +Предполагаемый uuid: `claude-code-status@git.vakhrushev.me`. + +## Источник данных — хуки Claude Code + +Пишутся в глобальный `~/.claude/settings.json`, чтобы работало во всех проектах. + +| Хук | Состояние | +|---|---| +| `SessionStart` | сессия появилась | +| `UserPromptSubmit` | работает | +| `Notification` | ждёт меня — разрешение или простой на вводе | +| `Stop` | ход закончен, ждёт ввода | +| `SessionEnd` | сессия исчезла | + +`Notification` и `Stop` **разделять в панели**: первое горит (заблокирована на +разрешении), второе просто ждёт (задача сделана). Слитые в одно, законченная +задача выглядит так же срочно, как заблокированная. + +### Файл состояния + +Один файл на сессию, иначе параллельные сессии затирают друг друга: + +``` +~/.local/state/claude-code-status/.json +``` + +Поля: состояние, `cwd`, метка времени последней смены, `$PPID` для проверки +живости. `SessionEnd` файл удаляет. + +**Проверить на первом же прогоне** (по памяти, не подтверждено): хук получает на +stdin JSON с `session_id`, `cwd`, `transcript_path`, `hook_event_name`. Имена +полей сверить с реальным вводом, а не доверять этой строке. + +Названия событий `SubagentStop`, `SessionEnd`, `UserPromptSubmit` подтверждены — +встречаются в конфиге плагина wakatime (`~/.claude/plugins/cache/wakatime/`), +там же можно подсмотреть рабочий пример hooks.json. + +## Правила отображения + +Приоритет агрегации: **хоть одна ждёт → «ждёт»**, иначе **хоть одна работает → +«работает»**, иначе тихо. + +- Показывается **дольше всех ждущая** сессия, не последняя: последняя и так + свежа в голове, забывается именно давняя. +- Несколько ждущих — счётчиком: `✋ dev-skills +2`. +- Для работающей полезно «работает 6 мин» — по этому решаешь, ждать или уходить. +- В меню — список сессий с **полным путём**: два worktree одного репозитория по + basename не различаются. + +## Известные шероховатости + +- **Протухание.** Убитый терминал не пришлёт `SessionEnd`, файл останется. + Лечится меткой времени плюс проверкой живости `$PPID` — не идеально, но + практично; индикатор гасит протухшие. +- **Клик в меню не переключит фокус** на нужный терминал: на Wayland расширению + это просто так не даётся. Меню информационное. +- **`Stop` не отличает «закончил» от «упал»** — оба выглядят как «ждёт ввода». + Для задачи «пора переключиться» разницы нет, но знать стоит. + +## Порядок работ + +1. Хуки и формат файла состояния — в `~/.claude/settings.json`. +2. **Проверить на живых сессиях** через `watch cat`, что состояния переключаются + правильно и поля stdin те, что ожидались. Здесь же выяснится, когда реально + срабатывает `Notification`. +3. Индикатор поверх заведомо верных данных. + +Порядок именно такой: отлаживать GJS и раскладку состояний одновременно — это +две неизвестные в одном уравнении. diff --git a/README.md b/README.md new file mode 100644 index 0000000..aecd6c2 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Claude Code Status + +GNOME Shell indicator that answers one question at a glance: **is any Claude +Code session waiting for me, and which one?** + +With several sessions open in different projects, the cost is not knowing what +each one is doing — it is noticing that one of them stopped an hour ago. The +panel names the session, not just the state. + +## States + +| Panel | State | Meaning | +|---|---|---| +| disc inside a ring | `blocked` | stuck on a permission prompt — it cannot proceed without you | +| filled disc | `waiting` | turn finished, waiting for your input | +| ring | `busy` | working | +| dim dashed ring | `idle` | started, nothing asked yet, or nothing running | + +`blocked` and `waiting` are kept apart on purpose. Merged into one "needs you", +a finished task looks as urgent as a blocked one, and the distinction is exactly +what decides whether to switch now or after the current thought. + +The panel shows the **highest-priority** session and, when others share that +state, a `+N` count. Within a state the **oldest** one wins: the session you +have forgotten about is the one that has been waiting longest, never the latest. + +## Install + +```sh +git clone ~/projects/private/claude-code-gnome-extension +cd ~/projects/private/claude-code-gnome-extension + +# 1. hooks: teach Claude Code to publish session state +./hooks/install.py + +# 2. extension: symlink, compile the schema, enable +ln -s "$PWD" ~/.local/share/gnome-shell/extensions/claude-code-status@git.vakhrushev.me +glib-compile-schemas schemas/ +gnome-extensions enable claude-code-status@git.vakhrushev.me +``` + +Running sessions pick the hooks up without restarting — Claude Code re-reads +`settings.json` as it changes. + +On Wayland, changing extension *code* still needs a logout; enabling it for the +first time does not. + +`./hooks/install.py --uninstall` removes the hook registration and leaves the +rest of `~/.claude/settings.json` untouched. A `.bak` copy is written on every +run. + +## How it works + +Claude Code hooks write one small JSON file per session to +`~/.local/state/claude-code-status/.json`; the extension watches +that directory with `Gio.FileMonitor`. Nothing polls, and there is no daemon — +a state change reaches the panel as soon as the hook returns. + +| Hook | Effect | +|---|---| +| `SessionStart` | session appears as `idle`, and dead sessions are swept | +| `UserPromptSubmit` | `busy` | +| `PostToolUse` | `busy` | +| `PreCompact` | `busy` | +| `Notification` | `blocked` or `waiting`, depending on `notification_type` | +| `Stop` | `waiting` | +| `SessionEnd` | file removed | + +`PostToolUse` is not 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 when the state actually changes, so the +usual case costs a process spawn and no I/O. + +`Stop` and `SessionEnd` are registered synchronously. Both fire as the process +is about to go quiet, and an async hook racing that exit gets killed before it +writes — `claude -p` was observed leaving a session pinned at `busy` forever. + +Hooks for one session run concurrently, so the whole read-decide-write runs +under an `flock` on `.json.lock`, and an event older than the stored +one is refused. Both are needed: the timestamp guard alone still lets a hook +that read the old state before a `Stop` write its stale decision afterwards. + +Sub-agents are not shown. A batch of eight tasks is one line, "working 40 min", +which is the right line: the fifth of eight workers is not asking for anything. + +## zellij + +When sessions run in zellij tabs, the menu shows the **tab name** and a click +switches to it. The lookup goes through `zellij action dump-layout`, matching a +session's working directory against pane directories — the layout dump carries +no pane ids, so `ZELLIJ_PANE_ID` cannot be used for it. Two sessions in one tab +are therefore indistinguishable, and a session whose `cwd` has moved since the +pane opened will not match. Rows that cannot be resolved stay inert rather than +pretending a click does something. + +Turn it off in Settings if you do not use zellij; it costs one process every +couple of minutes. + +## Known rough edges + +- **Stale sessions.** A killed terminal never sends `SessionEnd`. Liveness is + rechecked every 20 s against `/proc/` and the file is deleted, so a + killed session disappears within that window rather than lingering. When the + hook cannot identify the claude process at all it records pid 0 — "unknown", + never confused with "dead" — and those entries expire on age instead, after + 36 h. +- **`Stop` cannot tell "finished" from "gave up".** Both read as "waiting for + input", which is the same decision for you either way. +- **Focus follows the zellij tab, not the window.** gnome-terminal runs every + window under one shared server process, so a window cannot be matched by pid. + A window is raised only when its title names the zellij session; when no + window matches, the tab still switches and focus is left alone, because + raising an arbitrary terminal is worse than raising none. + +## Testing + +```sh +gjs -m tests/test-sessions.js # state reading, ordering, liveness, monitoring +tests/test-hook.sh # event -> state machine, locking, teardown +``` + +`lib/sessions.js` imports nothing from `resource:///org/gnome/shell`, which is +what keeps it runnable outside the compositor. + +To watch the raw hook events, create the marker file and every event will be +appended to it: + +```sh +touch ~/.local/state/claude-code-status/debug +``` diff --git a/extension.js b/extension.js new file mode 100644 index 0000000..17f3ac1 --- /dev/null +++ b/extension.js @@ -0,0 +1,16 @@ +import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +import { ClaudeStatusIndicator } from './lib/indicator.js'; + +export default class ClaudeCodeStatusExtension extends Extension { + enable() { + this._indicator = new ClaudeStatusIndicator(this); + Main.panel.addToStatusArea(this.uuid, this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/hooks/claude-status-hook.py b/hooks/claude-status-hook.py new file mode 100755 index 0000000..1c50337 --- /dev/null +++ b/hooks/claude-status-hook.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Claude Code hook -> per-session state file for the GNOME panel indicator. + +Reads one hook event as JSON on stdin and maps it to a session state, written +to $XDG_STATE_HOME/claude-code-status/.json. The GNOME extension +watches that directory; no polling, no daemon, no socket. + +Design notes that are easy to get wrong: + +* Writes are skipped when the state does not change. PostToolUse fires on every + tool call, and its only job here is to clear "blocked" once a permission has + been granted -- letting it rewrite the file each time would make the directory + monitor fire hundreds of times per turn for no new information. + +* Hooks are registered async, so two events can race (the last PostToolUse of a + turn against that turn's Stop). The whole read-decide-write runs under a file + lock and an event older than the stored one is refused; without both, a late + "busy" buries "waiting" and the panel claims a session is working while it + actually waits for input. + +* No stdlib import beyond what is needed: this runs once per tool call. +""" + +import fcntl +import json +import os +import sys +import time + +STATE_DIR = os.path.join( + os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"), + "claude-code-status", +) + +# Notification covers several unrelated things; only some mean "the session +# stopped and is asking me something". auth_success / elicitation_complete / +# elicitation_response are progress chatter and must not touch the state. +NOTIFICATION_STATES = { + "permission_prompt": "blocked", + "agent_needs_input": "blocked", + "elicitation_dialog": "blocked", + "idle_prompt": "waiting", + "agent_completed": "waiting", +} + +EVENT_STATES = { + "SessionStart": "idle", + "UserPromptSubmit": "busy", + "PreCompact": "busy", + "PostToolUse": "busy", + "Stop": "waiting", +} + + +def debug_log(event): + """Append raw events when a 'debug' marker file exists in the state dir. + + Gated on a file rather than an env var because the hook inherits claude's + environment, which cannot be changed without restarting the session. + """ + marker = os.path.join(STATE_DIR, "debug") + if not os.path.exists(marker): + return + try: + with open(marker, "a") as fh: + fh.write(json.dumps(event, sort_keys=True)[:2000] + "\n") + except OSError: + pass + + +def derive_state(event): + """Return the new state, 'end' to drop the session, or None to ignore.""" + name = event.get("hook_event_name") + if name == "SessionEnd": + return "end" + if name == "Notification": + return NOTIFICATION_STATES.get(event.get("notification_type")) + # A subagent finishing its own turn is not the session becoming free; the + # main agent is still working. SubagentStop is a distinct event and is not + # registered, but Stop carries agent_id when raised inside an agent. + if name == "Stop" and event.get("agent_id"): + return None + return EVENT_STATES.get(name) + + +def read_environ(pid): + """Environment of a process as a dict, empty if it is gone or not ours.""" + try: + with open("/proc/%d/environ" % pid, "rb") as fh: + raw = fh.read() + except OSError: + return {} + env = {} + for entry in raw.split(b"\0"): + if not entry: + continue + key, sep, value = entry.partition(b"=") + if sep: + env[key.decode("utf-8", "replace")] = value.decode("utf-8", "replace") + return env + + +def read_cmdline(pid): + try: + with open("/proc/%d/cmdline" % pid, "rb") as fh: + return fh.read().replace(b"\0", b" ").decode("utf-8", "replace") + except OSError: + return "" + + +def parent_of(pid): + try: + with open("/proc/%d/status" % pid, "r") as fh: + for line in fh: + if line.startswith("PPid:"): + return int(line.split()[1]) + except (OSError, ValueError): + pass + return 0 + + +def find_claude_pid(): + """Nearest ancestor that is the claude process itself, or 0 if unknown. + + The hook is spawned through a shell, so the immediate parent is usually not + claude. Walking beyond a handful of levels risks latching onto an outer + claude when one session drives another, so the search stops early. + + Returning 0 rather than guessing matters: the reader deletes state files + whose pid is gone, and the obvious fallback -- the shell that spawned this + hook -- exits milliseconds later, which would make the session flicker in + and out of the panel forever. + """ + pid = os.getppid() + for _ in range(6): + if pid <= 1: + break + # Matched anywhere in the command line, not just argv[0]: installs that + # run it as `node .../claude/cli.js` are just as valid as a direct one. + if "claude" in read_cmdline(pid): + return pid + pid = parent_of(pid) + return 0 + + +def alive(pid): + return pid > 0 and os.path.exists("/proc/%d" % pid) + + +def sweep_dead(keep): + """Drop state files whose claude process is gone. + + A killed terminal never sends SessionEnd, so files leak. Cleaning up on + SessionStart keeps the sweep off the hot path -- the extension only has to + hide stale entries, not own their lifetime. + """ + try: + names = os.listdir(STATE_DIR) + except OSError: + return + for name in names: + if not name.endswith(".json") or name == keep: + continue + path = os.path.join(STATE_DIR, name) + try: + with open(path, "r") as fh: + pid = json.load(fh).get("pid", 0) + except (OSError, ValueError, AttributeError): + continue + # pid 0 means the hook could not identify the process; there is nothing + # to test for liveness, so leave it to the reader's age cutoff. + if pid and not alive(pid): + for victim in (path, path + ".lock"): + try: + os.unlink(victim) + except OSError: + pass + + +def write_atomic(path, payload): + tmp = "%s.%d.tmp" % (path, os.getpid()) + with open(tmp, "w") as fh: + json.dump(payload, fh) + os.replace(tmp, path) + + +def apply_event(event, state, path, now): + """Read the current state, decide, and write. Must run under the lock.""" + if event.get("hook_event_name") == "SessionStart": + # Swept before any early return: a resumed session keeps its id, so its + # SessionStart finds an unchanged state and would otherwise bail out + # before ever reaching the sweep. + sweep_dead(keep=os.path.basename(path)) + + if state == "end": + for victim in (path, path + ".lock"): + try: + os.unlink(victim) + except OSError: + pass + return + + previous = None + try: + with open(path, "r") as fh: + previous = json.load(fh) + except (OSError, ValueError): + previous = None + if not isinstance(previous, dict): + previous = None + + # Normalised here so the comparison below is against what would actually be + # stored: comparing a stored "" to a raw notification message rewrites the + # file on every idle_prompt for no change at all. + message = event.get("message", "") if state == "blocked" else "" + + if previous: + # Auto-compaction raises SessionStart again, in the middle of a turn the + # session is still working on. Taking it at face value would flip a busy + # session to idle until the next tool call corrected it. + if event.get("hook_event_name") == "SessionStart" and event.get("source") == "compact": + return + # Refuse events that lost a race with a newer one. + if previous.get("event_ts", 0) > now: + return + # Nothing new to publish: stay quiet so the directory monitor stays quiet. + if previous.get("state") == state and previous.get("message", "") == message: + return + + claude_pid = find_claude_pid() + env = read_environ(claude_pid) if claude_pid else {} + + write_atomic(path, { + "session_id": event.get("session_id"), + "state": state, + "cwd": event.get("cwd") or "", + # Age is measured from the moment the state was entered, not from the + # last event, so "waiting 40 min" survives unrelated later writes. + "since": previous["since"] if previous and previous.get("state") == state else now, + "event_ts": now, + # 0 means "could not tell"; the reader must not take that for "dead". + "pid": claude_pid, + "event": event.get("hook_event_name", ""), + "notification_type": event.get("notification_type", ""), + "message": message, + "zellij_session": env.get("ZELLIJ_SESSION_NAME", ""), + "zellij_pane": env.get("ZELLIJ_PANE_ID", ""), + "transcript": event.get("transcript_path", ""), + }) + + +def main(): + now = time.time() + try: + event = json.load(sys.stdin) + except (ValueError, OSError): + return 0 + if not isinstance(event, dict): + return 0 + + session_id = event.get("session_id") + if not session_id or "/" in session_id: + return 0 + + debug_log(event) + + state = derive_state(event) + if state is None: + return 0 + + os.makedirs(STATE_DIR, exist_ok=True) + path = os.path.join(STATE_DIR, "%s.json" % session_id) + + # Hooks for one session run concurrently -- the last PostToolUse of a turn + # races that turn's Stop. Comparing timestamps is not enough on its own: + # without a lock both processes read the same "previous" and the loser's + # write still lands last, pinning a finished session at "busy". The lock is + # a separate file because write_atomic replaces the inode of the real one. + with open(path + ".lock", "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + apply_event(event, state, path, now) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception: + # A hook that fails loudly would spam every session with error output; + # a missing panel update is the cheaper failure. + sys.exit(0) diff --git a/hooks/install.py b/hooks/install.py new file mode 100755 index 0000000..7ff6782 --- /dev/null +++ b/hooks/install.py @@ -0,0 +1,93 @@ +#!/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() diff --git a/lib/format.js b/lib/format.js new file mode 100644 index 0000000..47e7591 --- /dev/null +++ b/lib/format.js @@ -0,0 +1,36 @@ +// Formatting helpers for panel and menu labels. + +import GLib from 'gi://GLib'; + +/** Compact age: "45s", "12m", "2h 5m". Seconds only below a minute, because + * the panel is glanced at, not read. */ +export function formatAge(seconds) { + const s = Math.max(0, Math.floor(seconds)); + if (s < 60) + return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) + return `${m}m`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m`; +} + +/** Last path segment, with ~ collapsed. Two worktrees of one repo share a + * basename, so this is a panel-only label; menus show the full path. */ +export function projectName(path) { + if (!path) + return '?'; + const trimmed = path.replace(/\/+$/, ''); + const base = trimmed.split('/').pop(); + return base || trimmed || '?'; +} + +/** Full path with the home directory shortened to "~". */ +export function shortenHome(path) { + if (!path) + return ''; + const home = GLib.get_home_dir(); + if (home && path.startsWith(home)) + return `~${path.slice(home.length)}`; + return path; +} diff --git a/lib/indicator.js b/lib/indicator.js new file mode 100644 index 0000000..7199de3 --- /dev/null +++ b/lib/indicator.js @@ -0,0 +1,394 @@ +// Panel button: one glance answers "does anything need me, and where". + +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Clutter from 'gi://Clutter'; +import GLib from 'gi://GLib'; +import Pango from 'gi://Pango'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js'; + +import { SessionStore, STATES } from './sessions.js'; +import { ZellijTabs } from './zellij.js'; +import { formatAge, projectName, shortenHome } from './format.js'; + +// Translated lazily: gettext is not bound yet while modules are being imported. +function stateLabel(state) { + switch (state) { + case 'blocked': return _('needs an answer'); + case 'waiting': return _('waiting for input'); + case 'busy': return _('working'); + case 'idle': return _('idle'); + default: return state; + } +} + +const TERMINAL_CLASSES = [ + 'gnome-terminal', 'org.gnome.terminal', 'kitty', 'alacritty', + 'foot', 'wezterm', 'konsole', 'xterm', 'ghostty', +]; + +export const ClaudeStatusIndicator = GObject.registerClass( +class ClaudeStatusIndicator extends PanelMenu.Button { + _init(extension) { + super._init(0.5, 'Claude Code Status', false); + + this._extension = extension; + this._settings = extension.getSettings(); + this._store = new SessionStore(); + this._zellij = new ZellijTabs(); + this._dotState = 'none'; + this._rows = []; + + this._buildPanel(); + this._buildMenu(); + + this._changedId = this._store.connect('changed', () => this._update()); + this._settingsChangedId = this._settings.connect('changed', () => this._update()); + this._store.start(); + } + + // ---- Panel widget ------------------------------------------------- + + _buildPanel() { + const box = new St.BoxLayout({ + style_class: 'panel-status-menu-box ccs-panel-box', + y_align: Clutter.ActorAlign.CENTER, + }); + + // Shape carries the state as well as colour does, so the indicator + // still reads under a monochrome theme or with colour vision deficiency: + // filled disc in a ring — blocked on a permission prompt; + // filled disc — turn finished, waiting for input; + // bright ring — working; + // dim dashed ring — idle, or nothing running. + this._dot = new St.DrawingArea({ + style_class: 'ccs-dot ccs-none', + y_align: Clutter.ActorAlign.CENTER, + }); + this._dot.set_width(16); + this._dot.set_height(16); + this._dot.connect('repaint', area => this._drawDot(area)); + box.add_child(this._dot); + + this._label = new St.Label({ + style_class: 'ccs-panel-label', + y_align: Clutter.ActorAlign.CENTER, + text: '', + }); + box.add_child(this._label); + + this.add_child(box); + } + + // ---- Menu --------------------------------------------------------- + + _buildMenu() { + this._summaryItem = new PopupMenu.PopupMenuItem('', { + reactive: false, + can_focus: false, + }); + this._summaryItem.add_style_class_name('ccs-summary'); + this.menu.addMenuItem(this._summaryItem); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._sessionsSection = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(this._sessionsSection); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + const prefsItem = new PopupMenu.PopupMenuItem(_('Settings')); + prefsItem.connect('activate', () => this._extension.openPreferences()); + this.menu.addMenuItem(prefsItem); + + this.menu.connect('open-state-changed', (_menu, open) => { + if (open) + this._refreshZellij(true); + }); + } + + // ---- Data → UI ---------------------------------------------------- + + _update() { + const sessions = this._store.sessions; + this._updatePanel(sessions); + this._updateMenu(sessions); + this._refreshZellij(false); + } + + _updatePanel(sessions) { + const hideWhenIdle = this._settings.get_boolean('hide-when-idle'); + const showName = this._settings.get_boolean('show-project-name'); + const showAge = this._settings.get_boolean('show-age'); + + const top = sessions[0] ?? null; + const state = top?.state ?? 'none'; + // Only sessions sharing the top state are counted: "+2" must mean two + // more that need the same thing, not two unrelated background sessions. + const peers = top ? sessions.filter(s => s.state === top.state).length : 0; + + this.visible = !(hideWhenIdle && (!top || top.state === 'idle')); + + let text = ''; + if (top && top.state !== 'idle') { + const parts = []; + if (showName) + parts.push(projectName(top.cwd)); + if (showAge) + parts.push(formatAge(this._ageOf(top))); + text = parts.join(' · '); + if (peers > 1) + text = text ? `${text} +${peers - 1}` : `+${peers - 1}`; + } + this._label.text = text; + this._label.visible = text !== ''; + + if (state !== this._dotState) { + this._dotState = state; + this._dot.style_class = `ccs-dot ccs-${state}`; + this._dot.queue_repaint(); + } + } + + _updateMenu(sessions) { + this._summaryItem.label.text = this._summaryText(sessions); + + // Rebuild only when the set of sessions or their states changed; ages + // alone are refreshed in place so an open menu does not flicker. + const signature = sessions + .map(s => `${s.sessionId}:${s.state}:${this._tabFor(s) ?? ''}`) + .join('|'); + if (signature !== this._rowSignature) { + this._rebuildRows(sessions); + this._rowSignature = signature; + } + for (const row of this._rows) { + const session = sessions.find(s => s.sessionId === row.sessionId); + if (!session) + continue; + row.age.text = formatAge(this._ageOf(session)); + row.subtitle.text = this._subtitleFor(session); + } + } + + _summaryText(sessions) { + if (!sessions.length) + return _('No Claude Code sessions'); + const counts = new Map(); + for (const s of sessions) + counts.set(s.state, (counts.get(s.state) ?? 0) + 1); + const parts = []; + for (const state of STATES) { + const n = counts.get(state); + if (n) + parts.push(`${n} ${stateLabel(state)}`); + } + return parts.join(', '); + } + + _rebuildRows(sessions) { + this._sessionsSection.removeAll(); + this._rows = []; + + if (!sessions.length) { + const empty = new PopupMenu.PopupMenuItem(_('Nothing running'), { + reactive: false, + can_focus: false, + }); + this._sessionsSection.addMenuItem(empty); + return; + } + + for (const session of sessions) + this._sessionsSection.addMenuItem(this._buildRow(session)); + } + + _buildRow(session) { + const tab = this._tabFor(session); + // Reactivity is decided at construction, not patched afterwards: + // PopupBaseMenuItem latches _activatable in its constructor, so a row + // switched to reactive=false later keeps the styling of a clickable one + // and still looks like it does something. + const item = new PopupMenu.PopupBaseMenuItem( + tab ? {} : { reactive: false, can_focus: false }); + item.add_style_class_name('ccs-row'); + + const column = new St.BoxLayout({ vertical: true, x_expand: true }); + + const top = new St.BoxLayout({ x_expand: true }); + const title = new St.Label({ + text: projectName(session.cwd), + style_class: `ccs-row-title ccs-${session.state}`, + x_expand: true, + }); + const age = new St.Label({ + text: formatAge(this._ageOf(session)), + style_class: 'ccs-row-age', + x_align: Clutter.ActorAlign.END, + }); + top.add_child(title); + top.add_child(age); + + const subtitle = new St.Label({ + text: this._subtitleFor(session), + style_class: 'ccs-row-subtitle', + }); + subtitle.clutter_text.line_wrap = false; + subtitle.clutter_text.ellipsize = Pango.EllipsizeMode.END; + + column.add_child(top); + column.add_child(subtitle); + item.add_child(column); + + if (tab) + item.connect('activate', () => this._switchTo(session, tab)); + + this._rows.push({ sessionId: session.sessionId, age, subtitle }); + return item; + } + + /** Second line: what the session needs, then where to find it. */ + _subtitleFor(session) { + const where = []; + const tab = this._tabFor(session); + if (tab) + where.push(`${_('tab')}: ${tab}`); + else if (session.zellijSession) + where.push(`${_('zellij')}: ${session.zellijSession}`); + where.push(shortenHome(session.cwd)); + + // A permission prompt is the one case where the reason matters more + // than the location: it says what is about to run. + if (session.state === 'blocked' && session.message) + return `${session.message} — ${where.join(' · ')}`; + return `${stateLabel(session.state)} · ${where.join(' · ')}`; + } + + _ageOf(session) { + if (!session.since) + return 0; + return GLib.get_real_time() / 1e6 - session.since; + } + + // ---- zellij --------------------------------------------------------- + + _tabFor(session) { + if (!this._settings.get_boolean('zellij-integration')) + return null; + return this._zellij.tabFor(session.zellijSession, session.cwd); + } + + _refreshZellij(force) { + if (!this._settings.get_boolean('zellij-integration')) + return; + const names = this._store.sessions.map(s => s.zellijSession).filter(Boolean); + if (!names.length) + return; + this._zellij.refresh(names, force) + .then(() => { + if (!this._destroyed) + this._updateMenu(this._store.sessions); + }) + .catch(e => logError(e, 'claude-code-status: zellij refresh failed')); + } + + _switchTo(session, tab) { + this._zellij.goToTab(session.zellijSession, tab); + this._focusTerminal(session); + } + + /** Best effort: raise a terminal window showing this zellij session. + * + * Matching by pid does not work for gnome-terminal, where every window + * belongs to one shared server process, so the window title is the only + * handle available -- and zellij puts the session name there. + */ + _focusTerminal(session) { + if (!session.zellijSession) + return; + // list_all_windows() rather than get_window_actors(): the latter is + // deprecated from GNOME 46 on, and this has to work across 45-48. + for (const win of global.display.list_all_windows()) { + const wmClass = (win.get_wm_class() ?? '').toLowerCase(); + if (!TERMINAL_CLASSES.some(c => wmClass.includes(c))) + continue; + // Only a window that names this zellij session is raised. Falling + // back to any terminal at all would raise an unrelated one, which + // is worse than leaving focus where the user put it. + if ((win.get_title() ?? '').includes(session.zellijSession)) { + Main.activateWindow(win); + return; + } + } + } + + // ---- Visuals -------------------------------------------------------- + + _drawDot(area) { + const cr = area.get_context(); + try { + const [w, h] = area.get_surface_size(); + const color = area.get_theme_node().get_foreground_color(); + const r = color.red / 255; + const g = color.green / 255; + const b = color.blue / 255; + const a = color.alpha / 255; + + const cx = w / 2; + const cy = h / 2; + const radius = Math.min(w, h) / 2 - 2; + + cr.setLineWidth(1.5); + cr.setSourceRGBA(r, g, b, a); + + switch (this._dotState) { + case 'blocked': + // Disc inside a ring: the loudest shape, for the only state + // where a session is stuck until you act. + cr.arc(cx, cy, radius, 0, 2 * Math.PI); + cr.stroke(); + cr.arc(cx, cy, radius * 0.55, 0, 2 * Math.PI); + cr.fill(); + break; + case 'waiting': + cr.arc(cx, cy, radius, 0, 2 * Math.PI); + cr.fill(); + break; + case 'busy': + cr.arc(cx, cy, radius, 0, 2 * Math.PI); + cr.stroke(); + break; + default: + cr.setDash([2, 2], 0); + cr.setSourceRGBA(r, g, b, a * 0.5); + cr.arc(cx, cy, radius, 0, 2 * Math.PI); + cr.stroke(); + cr.setDash([], 0); + break; + } + } finally { + cr.$dispose(); + } + } + + // ---- Teardown ------------------------------------------------------- + + destroy() { + this._destroyed = true; + this._zellij.destroy(); + if (this._changedId) { + this._store.disconnect(this._changedId); + this._changedId = 0; + } + if (this._settingsChangedId) { + this._settings.disconnect(this._settingsChangedId); + this._settingsChangedId = 0; + } + this._store.destroy(); + super.destroy(); + } +}); diff --git a/lib/sessions.js b/lib/sessions.js new file mode 100644 index 0000000..11c4143 --- /dev/null +++ b/lib/sessions.js @@ -0,0 +1,235 @@ +// Reads the per-session state files written by the Claude Code hook and keeps +// them in sync with the filesystem. +// +// The hook only writes on an actual state change, so a directory monitor is +// enough and there is nothing to poll. The timer here exists for two other +// reasons: displayed ages go stale on their own, and a session whose terminal +// was killed never sends SessionEnd, so liveness has to be rechecked. + +import GObject from 'gi://GObject'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +Gio._promisify(Gio.File.prototype, 'enumerate_children_async'); +Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async'); +Gio._promisify(Gio.File.prototype, 'load_contents_async'); + +// Aggregation order: a session blocked on a permission prompt is the only one +// that is actually stuck, so it outranks one that merely finished its turn. +export const STATES = ['blocked', 'waiting', 'busy', 'idle']; + +const KNOWN = new Set(STATES); +const LIVENESS_INTERVAL = 20; // seconds + +// Fallback for sessions whose process could not be identified (pid 0): there is +// nothing to test for liveness, so age is the only signal left. Long enough +// that a session genuinely left waiting overnight is still listed in the +// morning, which is exactly the case this indicator exists for. +const UNKNOWN_PID_MAX_AGE = 36 * 3600; // seconds + +export function stateRank(state) { + const i = STATES.indexOf(state); + return i < 0 ? STATES.length : i; +} + +export function stateDir() { + const base = GLib.getenv('XDG_STATE_HOME') || + GLib.build_filenamev([GLib.get_home_dir(), '.local', 'state']); + return GLib.build_filenamev([base, 'claude-code-status']); +} + +export const SessionStore = GObject.registerClass({ + Signals: { 'changed': {} }, +}, class SessionStore extends GObject.Object { + _init() { + super._init(); + this._dir = Gio.File.new_for_path(stateDir()); + this._sessions = []; + this._monitor = null; + this._debounceId = 0; + this._timerId = 0; + this._cancellable = new Gio.Cancellable(); + this._loading = false; + this._loadAgain = false; + } + + get sessions() { + return this._sessions; + } + + start() { + // The directory is created by the first hook run, which may not have + // happened yet; monitoring a missing directory still reports its + // creation, so there is nothing to wait for. + try { + this._monitor = this._dir.monitor_directory(Gio.FileMonitorFlags.WATCH_MOVES, null); + this._monitor.connect('changed', () => this._scheduleLoad()); + } catch (e) { + logError(e, 'claude-code-status: cannot monitor state directory'); + } + + this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, LIVENESS_INTERVAL, () => { + // Ages advance and processes die without any file changing, so this + // tick is what makes a killed terminal disappear from the panel. + this._load(); + return GLib.SOURCE_CONTINUE; + }); + + this._load(); + } + + // One atomic write lands as several monitor events (created, moved, changed). + // Collapsing them keeps a burst of five sessions from causing five reloads. + _scheduleLoad() { + if (this._debounceId) + GLib.Source.remove(this._debounceId); + this._debounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 120, () => { + this._debounceId = 0; + this._load(); + return GLib.SOURCE_REMOVE; + }); + } + + async _load() { + if (this._loading) { + this._loadAgain = true; + return; + } + this._loading = true; + const cancellable = this._cancellable; + try { + const sessions = await this._readAll(cancellable); + if (cancellable.is_cancelled()) + return; + sessions.sort((a, b) => { + const byState = stateRank(a.state) - stateRank(b.state); + // Oldest first within a state: the session you forgot about is + // the one that has been waiting longest, not the latest one. + return byState !== 0 ? byState : a.since - b.since; + }); + this._sessions = sessions; + // Emitted unconditionally: even with no structural change the + // displayed ages have advanced, and redrawing a handful of labels + // is cheaper than tracking what moved. + this.emit('changed'); + } catch (e) { + if (!cancellable.is_cancelled()) + logError(e, 'claude-code-status: failed to read session state'); + } finally { + this._loading = false; + if (this._loadAgain) { + this._loadAgain = false; + this._load(); + } + } + } + + async _readAll(cancellable) { + let enumerator; + try { + enumerator = await this._dir.enumerate_children_async( + 'standard::name', Gio.FileQueryInfoFlags.NONE, + GLib.PRIORITY_DEFAULT, cancellable); + } catch (e) { + // No directory yet means no sessions have ever run; not an error. + if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + return []; + throw e; + } + + const names = []; + for (;;) { + const batch = await enumerator.next_files_async( + 32, GLib.PRIORITY_DEFAULT, cancellable); + if (!batch.length) + break; + for (const info of batch) { + const name = info.get_name(); + // ".tmp" files are half-written state; "debug" is the hook's + // opt-in event log and is not a session. + if (name.endsWith('.json')) + names.push(name); + } + } + + const sessions = []; + for (const name of names) { + const session = await this._readOne(name, cancellable); + if (session) + sessions.push(session); + } + return sessions; + } + + async _readOne(name, cancellable) { + const file = this._dir.get_child(name); + let raw; + try { + const [contents] = await file.load_contents_async(cancellable); + raw = JSON.parse(new TextDecoder().decode(contents)); + } catch (e) { + // A file replaced mid-read, or truncated by a crash: skip it and + // let the next monitor event pick up the good version. + return null; + } + if (!raw || typeof raw !== 'object') + return null; + + const pid = Number(raw.pid) || 0; + const eventTs = Number(raw.event_ts) || 0; + const age = GLib.get_real_time() / 1e6 - eventTs; + // pid 0 is "the hook could not tell", not "dead": treating it as dead + // would hide a perfectly live session, so those fall back to an age + // cutoff instead. + const gone = pid > 0 ? !isAlive(pid) : age > UNKNOWN_PID_MAX_AGE; + if (gone) { + // The terminal was killed without a SessionEnd hook. Removing the + // file here (rather than only hiding it) keeps the directory from + // growing forever across reboots. + // The hook's lock file goes with it; dropping only the state file + // would leave one empty ".lock" behind per session, forever. + for (const victim of [file, this._dir.get_child(`${name}.lock`)]) { + victim.delete_async(GLib.PRIORITY_LOW, null, (obj, res) => { + try { + obj.delete_finish(res); + } catch (e) { + // Already gone: the hook's own sweep got there first. + } + }); + } + return null; + } + + const state = KNOWN.has(raw.state) ? raw.state : 'idle'; + return { + sessionId: String(raw.session_id ?? name.replace(/\.json$/, '')), + state, + cwd: String(raw.cwd ?? ''), + since: Number(raw.since) || 0, + pid, + message: String(raw.message ?? ''), + notificationType: String(raw.notification_type ?? ''), + zellijSession: String(raw.zellij_session ?? ''), + zellijPane: String(raw.zellij_pane ?? ''), + }; + } + + destroy() { + this._cancellable.cancel(); + if (this._debounceId) { + GLib.Source.remove(this._debounceId); + this._debounceId = 0; + } + if (this._timerId) { + GLib.Source.remove(this._timerId); + this._timerId = 0; + } + this._monitor?.cancel(); + this._monitor = null; + this._sessions = []; + } +}); + +function isAlive(pid) { + return pid > 0 && GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS); +} diff --git a/lib/zellij.js b/lib/zellij.js new file mode 100644 index 0000000..b95af4f --- /dev/null +++ b/lib/zellij.js @@ -0,0 +1,162 @@ +// Maps a session's working directory to the zellij tab it is running in. +// +// Knowing a session waits for you is only half the answer; the other half is +// where to look. When sessions live in zellij tabs, the tab name is a better +// answer than a path, and zellij can be told to switch to it. +// +// `zellij action dump-layout` prints tab names with each pane's cwd but no pane +// ids, so ZELLIJ_PANE_ID from the hook cannot be used for the lookup and the +// match goes through the working directory instead. That is best-effort by +// nature: two sessions in one tab are indistinguishable, and a session whose +// cwd has moved since the pane opened will not match. + +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async'); + +// Layouts only change when tabs are rearranged, and opening the menu forces a +// refresh anyway -- so the background TTL is long enough that the periodic tick +// does not spawn a zellij process every time it runs. +const CACHE_TTL = 120; // seconds + +export class ZellijTabs { + constructor() { + this._cache = new Map(); // zellij session -> { at, tabs: [{name, cwds}] } + this._inFlight = new Map(); + this._available = null; + this._cancellable = new Gio.Cancellable(); + } + + /** Tab name for a working directory, or null when unknown. */ + tabFor(zellijSession, cwd) { + const entry = this._cache.get(zellijSession); + if (!entry || !cwd) + return null; + // Longest matching prefix wins: a pane opened at the repo root must not + // outrank one opened directly in the subdirectory the session runs in. + let best = null; + let bestLen = -1; + for (const tab of entry.tabs) { + for (const paneCwd of tab.cwds) { + if (cwd !== paneCwd && !cwd.startsWith(`${paneCwd}/`)) + continue; + if (paneCwd.length > bestLen) { + bestLen = paneCwd.length; + best = tab.name; + } + } + } + return best; + } + + /** Refresh the layout of every zellij session in use, at most once per TTL. + * `force` bypasses the TTL for the one moment it matters: the user just + * opened the menu and may have rearranged tabs since the last look. */ + async refresh(zellijSessions, force = false) { + if (this._available === false) + return; + const now = GLib.get_monotonic_time() / 1e6; + const work = []; + for (const name of new Set(zellijSessions)) { + if (!name) + continue; + const entry = this._cache.get(name); + if (!force && entry && now - entry.at < CACHE_TTL) + continue; + work.push(this._refreshOne(name, now)); + } + await Promise.all(work); + } + + async _refreshOne(name, now) { + // Collapse concurrent refreshes of the same session; the menu opening + // and the periodic tick can otherwise fire two subprocesses at once. + if (this._inFlight.has(name)) + return this._inFlight.get(name); + const promise = this._dumpLayout(name) + .then(layout => { + if (layout !== null) + this._available = true; + // A failure is cached as an empty layout, not left uncached: + // otherwise a zellij session that was renamed or killed while + // its claude process lives on fails the TTL check every time + // and forks a process on every tick, forever. + this._cache.set(name, { + at: now, + tabs: layout === null ? [] : parseLayout(layout), + }); + }) + .finally(() => this._inFlight.delete(name)); + this._inFlight.set(name, promise); + return promise; + } + + async _dumpLayout(session) { + try { + const proc = Gio.Subprocess.new( + ['zellij', '--session', session, 'action', 'dump-layout'], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE); + const [stdout] = await proc.communicate_utf8_async(null, this._cancellable); + if (!proc.get_successful()) + return null; + return stdout ?? ''; + } catch (e) { + // zellij not installed, or not on the shell's PATH: stop trying. + if (e.matches?.(GLib.SpawnError, GLib.SpawnError.NOENT)) + this._available = false; + return null; + } + } + + /** Abandon any layout dump still running; the extension is going away. */ + destroy() { + this._cancellable.cancel(); + this._cache.clear(); + this._inFlight.clear(); + } + + /** Switch the given zellij session to a tab. Fire and forget. */ + goToTab(session, tab) { + try { + Gio.Subprocess.new( + ['zellij', '--session', session, 'action', 'go-to-tab-name', tab], + Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE); + } catch (e) { + logError(e, 'claude-code-status: zellij go-to-tab-name failed'); + } + } +} + +/** Extract tab names and their pane working directories from a KDL layout. + * + * Parsed with line matching rather than a KDL parser: the two constructs that + * matter are one line each, and a dependency-free extension cannot pull one in. + */ +export function parseLayout(text) { + const tabs = []; + // A layout-level `cwd "..."` is the base for panes that store a relative one. + const baseMatch = text.match(/^\s*cwd\s+"([^"]*)"/m); + const base = baseMatch ? baseMatch[1] : ''; + let current = null; + for (const line of text.split('\n')) { + const tabMatch = line.match(/^\s*tab\s.*?name="([^"]*)"/); + if (tabMatch) { + current = { name: tabMatch[1], cwds: [] }; + tabs.push(current); + continue; + } + if (!current) + continue; + const paneMatch = line.match(/^\s*pane\s.*?\bcwd="([^"]*)"/); + if (paneMatch) { + const cwd = paneMatch[1]; + const absolute = cwd.startsWith('/') + ? cwd + : GLib.build_filenamev([base, cwd]); + if (!current.cwds.includes(absolute)) + current.cwds.push(absolute); + } + } + return tabs; +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..40dc3cb --- /dev/null +++ b/metadata.json @@ -0,0 +1,10 @@ +{ + "uuid": "claude-code-status@git.vakhrushev.me", + "name": "Claude Code Status", + "description": "Shows what your Claude Code sessions are doing in the top bar: which one is blocked on a permission prompt, which one finished and waits for input, and which one is still working. Fed by Claude Code hooks, so it updates the moment a session changes state.", + "shell-version": ["45", "46", "47", "48"], + "url": "https://git.vakhrushev.me/av/claude-code-gnome-extension", + "settings-schema": "org.gnome.shell.extensions.claude-code-status", + "gettext-domain": "claude-code-status", + "version": 1 +} diff --git a/prefs.js b/prefs.js new file mode 100644 index 0000000..ac1d205 --- /dev/null +++ b/prefs.js @@ -0,0 +1,106 @@ +import Adw from 'gi://Adw'; +import Gtk from 'gi://Gtk'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +import { ExtensionPreferences, gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +export default class ClaudeCodeStatusPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings(); + + const page = new Adw.PreferencesPage({ + title: _('General'), + icon_name: 'utilities-terminal-symbolic', + }); + window.add(page); + + const dispGroup = new Adw.PreferencesGroup({ title: _('Panel') }); + page.add(dispGroup); + + dispGroup.add(this._switchRow(settings, 'show-project-name', + _('Show project name'), + _('Name the session that needs attention, not just its state.'))); + dispGroup.add(this._switchRow(settings, 'show-age', + _('Show time in state'), + _('How long it has been working, or waiting for you.'))); + dispGroup.add(this._switchRow(settings, 'hide-when-idle', + _('Hide when nothing is running'), + _('Remove the indicator from the panel while no session is active.'))); + + const zellijGroup = new Adw.PreferencesGroup({ + title: _('zellij'), + description: _('Sessions running inside zellij can be located by tab name instead of by path.'), + }); + page.add(zellijGroup); + + zellijGroup.add(this._switchRow(settings, 'zellij-integration', + _('Resolve tab names'), + _('Show the zellij tab in the menu and switch to it on click. Ignored when zellij is not installed.'))); + + // --- Hooks --------------------------------------------------------- + // The indicator is only as good as the hooks feeding it, and a silent + // panel looks identical whether nothing is running or nothing is + // installed. Say which it is. + const hooksGroup = new Adw.PreferencesGroup({ + title: _('Hooks'), + description: _('Claude Code writes one state file per session; the panel watches them.'), + }); + page.add(hooksGroup); + + hooksGroup.add(new Adw.ActionRow({ + title: _('Status'), + subtitle: this._hooksStatus(), + })); + hooksGroup.add(new Adw.ActionRow({ + title: _('State directory'), + subtitle: this._stateDir(), + })); + + const installRow = new Adw.ActionRow({ + title: _('Install command'), + subtitle: `${this.path}/hooks/install.py`, + }); + const copyButton = new Gtk.Button({ + icon_name: 'edit-copy-symbolic', + valign: Gtk.Align.CENTER, + tooltip_text: _('Copy to clipboard'), + }); + copyButton.connect('clicked', () => { + window.get_clipboard().set(`${this.path}/hooks/install.py`); + }); + installRow.add_suffix(copyButton); + hooksGroup.add(installRow); + } + + _stateDir() { + const base = GLib.getenv('XDG_STATE_HOME') || + GLib.build_filenamev([GLib.get_home_dir(), '.local', 'state']); + return GLib.build_filenamev([base, 'claude-code-status']); + } + + _hooksStatus() { + const path = GLib.build_filenamev([GLib.get_home_dir(), '.claude', 'settings.json']); + try { + const [ok, bytes] = GLib.file_get_contents(path); + if (!ok) + return _('~/.claude/settings.json is unreadable'); + const settings = JSON.parse(new TextDecoder().decode(bytes)); + const events = Object.entries(settings.hooks ?? {}) + .filter(([, groups]) => groups.some(g => + (g.hooks ?? []).some(h => (h.command ?? '').includes('claude-status-hook.py')))) + .map(([event]) => event); + if (!events.length) + return _('Not installed — run the install command below, then restart your sessions'); + return `${_('Installed for')}: ${events.join(', ')}`; + } catch (e) { + return _('~/.claude/settings.json could not be parsed'); + } + } + + _switchRow(settings, key, title, subtitle) { + const row = new Adw.SwitchRow({ title, subtitle }); + settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT); + return row; + } +} diff --git a/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml b/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml new file mode 100644 index 0000000..1d1908f --- /dev/null +++ b/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml @@ -0,0 +1,26 @@ + + + + + true + Show the project name in the panel + Name the session that needs attention, not just its state. With several sessions running, the state alone does not say which terminal to go to. + + + true + Show how long the session has been in this state + How long a session has been working, or waiting, is what decides whether to switch to it now or leave it running. + + + false + Hide the indicator when nothing is running + Remove the indicator from the panel while there are no sessions, or all of them are idle. + + + true + Resolve zellij tab names + Look up which zellij tab each session runs in, show it in the menu, and let a click switch to that tab. Requires the zellij command; harmless when it is absent. + + + diff --git a/stylesheet.css b/stylesheet.css new file mode 100644 index 0000000..01b4d28 --- /dev/null +++ b/stylesheet.css @@ -0,0 +1,48 @@ +/* The dot is drawn with the widget's foreground colour, so state colours are + set here as plain `color` and the drawing code stays theme-agnostic. */ + +.ccs-panel-box { + spacing: 4px; +} + +.ccs-panel-label { + font-size: 0.9em; +} + +.ccs-dot.ccs-blocked, +.ccs-row-title.ccs-blocked { + color: #e01b24; +} + +.ccs-dot.ccs-waiting, +.ccs-row-title.ccs-waiting { + color: #e5a50a; +} + +.ccs-dot.ccs-busy, +.ccs-row-title.ccs-busy { + color: #33d17a; +} + +.ccs-summary { + font-weight: bold; +} + +.ccs-row { + spacing: 8px; +} + +.ccs-row-title { + font-weight: bold; +} + +.ccs-row-age { + font-feature-settings: "tnum"; + opacity: 0.7; +} + +.ccs-row-subtitle { + font-size: 0.85em; + opacity: 0.65; + max-width: 460px; +} diff --git a/tests/test-hook.sh b/tests/test-hook.sh new file mode 100755 index 0000000..c12842b --- /dev/null +++ b/tests/test-hook.sh @@ -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" diff --git a/tests/test-sessions.js b/tests/test-sessions.js new file mode 100644 index 0000000..c1ec03a --- /dev/null +++ b/tests/test-sessions.js @@ -0,0 +1,110 @@ +#!/usr/bin/gjs -m +// Exercises SessionStore against real files, under real GJS/GIO. +// +// sessions.js deliberately imports nothing from resource:///org/gnome/shell, +// which is what makes this runnable outside the compositor -- the part of the +// extension most likely to be wrong (liveness, ordering, monitoring, partial +// reads) is also the part that can be tested for real. +// +// Run: gjs -m tests/test-sessions.js + +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; + +import { SessionStore, stateRank } from '../lib/sessions.js'; + +const DIR = GLib.build_filenamev([GLib.get_tmp_dir(), `ccs-test-${GLib.random_int()}`]); +GLib.setenv('XDG_STATE_HOME', DIR, true); +const STATE = GLib.build_filenamev([DIR, 'claude-code-status']); +GLib.mkdir_with_parents(STATE, 0o755); + +let failures = 0; +function check(name, condition, detail = '') { + const mark = condition ? 'ok ' : 'FAIL'; + if (!condition) + failures++; + print(`${mark} ${name}${detail ? ` (${detail})` : ''}`); +} + +const now = GLib.get_real_time() / 1e6; +function write(id, state, cwd, agoSeconds, pid) { + const payload = { + session_id: id, state, cwd, since: now - agoSeconds, + event_ts: now, pid, event: 'test', notification_type: '', + message: '', zellij_session: 'ztest', zellij_pane: '1', transcript: '', + }; + GLib.file_set_contents( + GLib.build_filenamev([STATE, `${id}.json`]), JSON.stringify(payload)); +} + +// A process that exists for the duration of the test, and one that does not. +const livePid = new TextDecoder().decode( + GLib.file_get_contents('/proc/self/stat')[1]).split(' ')[0]; +const deadPid = 4194303; // above the default pid_max, so it cannot exist + +write('s-busy', 'busy', '/home/u/proj-busy', 30, livePid); +write('s-blocked', 'blocked', '/home/u/proj-blocked', 10, livePid); +write('s-wait-new', 'waiting', '/home/u/proj-new', 60, livePid); +write('s-wait-old', 'waiting', '/home/u/proj-old', 3600, livePid); +write('s-dead', 'waiting', '/home/u/proj-dead', 5, deadPid); +write('s-idle', 'idle', '/home/u/proj-idle', 5, livePid); +GLib.file_set_contents(GLib.build_filenamev([STATE, 'notes.txt']), 'ignored'); +GLib.file_set_contents(GLib.build_filenamev([STATE, 'half.json']), '{"broken'); + +const loop = new GLib.MainLoop(null, false); +const store = new SessionStore(); +let round = 0; + +store.connect('changed', () => { + round++; + const s = store.sessions; + + if (round === 1) { + check('ranks order blocked before waiting before busy', + stateRank('blocked') < stateRank('waiting') && + stateRank('waiting') < stateRank('busy')); + + check('dead session dropped', !s.some(x => x.sessionId === 's-dead')); + check('truncated file skipped, others survive', s.length === 5, + `got ${s.length}: ${s.map(x => x.sessionId).join(',')}`); + check('non-json ignored', !s.some(x => x.sessionId.includes('notes'))); + + check('blocked sorts first', s[0]?.sessionId === 's-blocked', s[0]?.sessionId); + check('longest wait precedes newer wait', + s[1]?.sessionId === 's-wait-old' && s[2]?.sessionId === 's-wait-new', + `${s[1]?.sessionId}, ${s[2]?.sessionId}`); + check('busy after waiting', s[3]?.sessionId === 's-busy', s[3]?.sessionId); + check('idle last', s[4]?.sessionId === 's-idle', s[4]?.sessionId); + + check('dead session file removed from disk', + !GLib.file_test(GLib.build_filenamev([STATE, 's-dead.json']), GLib.FileTest.EXISTS)); + + // A hook writing a new session must reach the panel without polling. + write('s-fresh', 'blocked', '/home/u/proj-fresh', 1, livePid); + return; + } + + if (s.some(x => x.sessionId === 's-fresh')) { + check('directory monitor picked up a new session', true); + check('new blocked session takes the top slot', + s[0].state === 'blocked', s[0].sessionId); + store.destroy(); + loop.quit(); + } +}); + +store.start(); + +GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 10, () => { + check('finished before timeout', false, 'monitor never fired'); + loop.quit(); + return GLib.SOURCE_REMOVE; +}); + +loop.run(); + +Gio.File.new_for_path(DIR).trash_async?.(GLib.PRIORITY_LOW, null, null); +GLib.spawn_command_line_sync(`rm -rf ${DIR}`); + +print(failures ? `\n${failures} failure(s)` : '\nall passed'); +imports.system.exit(failures ? 1 : 0);