# 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. There are deliberately **no desktop notifications**. `notify-send` from a hook would have been far cheaper to build, and it is the wrong shape: a session that has been waiting twenty minutes needs to stay visible, and a notification is gone the moment it is dismissed. The panel is the whole channel. ## 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 ```