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.
131 lines
5.5 KiB
Markdown
131 lines
5.5 KiB
Markdown
# 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 <this repo> ~/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/<session_id>.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 `<session_id>.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/<pid>` 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
|
|
```
|