Four agents reviewed this in parallel -- correctness, GNOME integration, edge cases, security. Everything below was reproduced before being fixed; several findings that survived the first reading did not survive a probe and are not here. State machine, the two that mattered most. A pending permission prompt was erased by any subagent bookkeeping event: SubagentStop or the next PreToolUse recomputed the state from scratch, so a session sat at "working" with a dialog open and nothing ever raised it again. Blocked now outlives everything except evidence the question was answered. Separately, the stale-event guard refused whole events, including the subagent counter's increments and decrements -- but those are deltas and deltas commute, so a "+1" that lost a timestamp race left the count short and the batch freed the session while a subagent was still running. The guard now gates the state decision only. Corrupt or hostile state files could wedge the panel or take the hook down for every session: a non-numeric pid raised inside sweep_dead before the hook wrote its own file, so one bad byte stopped new sessions appearing at all. Numbers read back from disk are coerced, one unreadable file no longer aborts the sweep, and a stored timestamp far in the future -- corruption, or a clock stepped backwards by NTP -- no longer refuses every later event forever. Resource bounds, all in the compositor process. A state file was read whole with no size check: a symlink to /dev/zero took a test process past 4 GB in three seconds, which in gnome-shell ends the session. Sizes are checked before the read, sessions and zellij subprocesses are capped, labels ellipsize, and cwd and messages are truncated at the hook. Teardown hung off an overridden destroy(), which only runs when JS calls it. An actor destroyed any other way -- another extension rebuilding the panel boxes -- left the timer and the file monitor running against a disposed actor. It is a destroy signal now. The zellij child is killed rather than merely abandoned. The glyph was pinned to physical pixels and rendered half-size on HiDPI; size comes from the stylesheet, and the foreground colour is normalised by inspection rather than assuming which colour struct the shell hands back. Chip labels: non-Latin names all collapsed to "?", because the split treated every Cyrillic letter as a separator -- notable for a tool whose own README is Russian. Seniority also ranked by time-in-state rather than session age, so after a shell restart the older session could take the digit; the hook now records when the session began. zellij: a dump ends with new_tab_template and swap_tiled_layout blocks whose tab lines carry no name, and their panes were being attached to the last real tab -- which then answered for every unmatched directory, confidently and wrongly. install.py no longer widens the mode of a settings.json someone narrowed to 0600, no longer overwrites the pristine .bak on a second run, no longer replaces a symlink out of a dotfiles repository with a regular file, and quotes the hook path. The debug log is capped and README now says plainly that it records prompts verbatim. Not fixed, deliberately: the panel does push the clock about 70 px left with three labelled chips, which is inherent to putting them in the centre box; two different projects abbreviating alike still read as one project with a digit; GNOME 48 remains unverified for the colour struct and for St.BoxLayout's vertical property, both flagged rather than guessed at.
Claude Code Status
Индикатор для GNOME Shell, отвечающий на один вопрос: какая сессия Claude Code ждёт меня прямо сейчас?
Когда открыто несколько сессий в разных проектах, дорого не «не знать, чем каждая занята», а не заметить, что одна из них встала час назад.
Справа от часов стоит по чипу на сессию — значок состояния и трёхбуквенное имя проекта:
◉ ds ● dc ● ds2 ○ pps ○ umb 9:41
│ │ │ │ └ umbar, работает
│ │ │ └ pet-project-server, работает
│ │ └ вторая сессия в dev-skills, ждёт
│ └ dev-conventions, ждёт
└ dev-skills, спрашивает
Состояния
| Значок | Состояние | Что значит |
|---|---|---|
| диск в кольце | blocked |
спрашивает — разрешение или вопрос с вариантами; строка ввода занята |
| закрашенный диск | waiting |
ход закончен, строка ввода свободна |
| кольцо | busy |
работает |
blocked и waiting разведены намеренно. Слитые в одно «требует внимания»,
законченная задача выглядит так же срочно, как заблокированная, — а именно это
различие и решает, переключаться сейчас или после текущей мысли.
Состояний ровно три. Четвёртое, «тихо», было и убрано: оно ставилось только
событием SessionStart и в него не было возврата, так что означало не «давно
без дела», а «сессию открыли и ещё ни разу ничего не спросили» — состояние
длиной в несколько секунд, занимавшее форму в панели. Свежая сессия ждёт
первого промпта ровно так же, как доделавшая ход ждёт следующего, и теперь обе
называются waiting.
Дальше этого деление не идёт, и не по лени: запрос разрешения и вопрос с
вариантами приходят под одним и тем же notification_type, с одинаковым родовым
текстом «Claude needs your permission». Различить их можно было бы только через
PreToolUse — ценой записи файла на каждый вызов инструмента.
Чипов показывается три (настраивается), остальные сворачиваются в +N. Панель
живёт в центральном боксе рядом с часами, и без предела достаточно открытых
сессий сдвинули бы часы с центра.
Чипы идут по срочности, а внутри одного состояния первой стоит самая давняя: забывается та, что ждёт дольше всех, а не последняя. Время в состоянии показывается только у первого чипа: пять счётчиков рядом — это ряд чисел, а не ответ на вопрос.
Метки чипов
Имя проекта сжимается до трёх знаков: если в имени несколько сегментов
(-, _, camelCase) — инициалы, иначе первые буквы.
| Проект | Чип |
|---|---|
dev-skills |
ds |
dev-conventions |
dc |
pet-project-server |
pps |
claude-code-gnome-extension |
ccg |
jellybit |
jel |
Инициалы, а не просто префикс, важны больше, чем кажется: префикс слил бы
dev-skills и dev-conventions в один dev — ровно тот случай, который надо
развести.
При совпадении — включая две сессии в одном проекте, где cwd один и тот же —
добавляется цифра по старшинству: ds, ds2, ds3. Цифра съедает базу, а не
удлиняет метку, чтобы ряд не расползался.
Два правила держат метки на месте, и без них вся затея рассыпается:
- Назначение идёт от самой давней сессии, так что цифру берёт только что запущенная, а не та, на которую ты сейчас смотришь.
- Выданная метка закреплена за сессией до её конца — даже после того, как закрылась сессия, из-за которой появилась цифра. Метка, переехавшая под рукой, хуже метки с цифрой, которая уже не выглядит нужной.
Сокращение отключается в настройках — тогда в чипах полные имена проектов.
В меню строка начинается с той же метки, чтобы соответствие «ds — это
dev-skills» читалось, а не угадывалось.
Уведомлений на рабочий стол нет — сознательно. notify-send из хука было бы
куда дешевле сделать, но форма неверная: сессия, ждущая двадцать минут, должна
оставаться на виду, а уведомление исчезает в момент, когда его смахнули. Панель
здесь — единственный канал.
Установка
git clone <этот репозиторий> ~/projects/private/claude-code-gnome-extension
cd ~/projects/private/claude-code-gnome-extension
# 1. хуки: научить Claude Code публиковать состояние сессий
./hooks/install.py
# 2. расширение: симлинк, компиляция схемы, включение
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
Уже запущенные сессии подхватывают хуки без перезапуска — Claude Code перечитывает
settings.json по мере изменения.
На Wayland правки кода расширения по-прежнему требуют релогина; первое включение — нет.
./hooks/install.py --uninstall снимает регистрацию хуков, не трогая остальное в
~/.claude/settings.json. Копия .bak пишется при каждом запуске.
Как это работает
Хуки Claude Code пишут по одному небольшому JSON-файлу на сессию в
~/.local/state/claude-code-status/<session_id>.json; расширение следит за этим
каталогом через Gio.FileMonitor. Ничего не опрашивается, демона нет — смена
состояния доходит до панели, как только хук завершился.
| Хук | Действие |
|---|---|
SessionStart |
сессия появляется как waiting, мёртвые подчищаются |
UserPromptSubmit |
busy |
PostToolUse |
busy |
PreCompact |
busy |
Notification |
blocked или waiting, в зависимости от notification_type |
Stop |
waiting |
SessionEnd |
файл удаляется |
PostToolUse не избыточен: это единственное событие, срабатывающее после выдачи
разрешения, — без него сессия остаётся blocked в панели до конца хода. Пишет он
только при реальной смене состояния, так что обычный случай стоит запуска процесса
и нулевого ввода-вывода.
Stop и SessionEnd зарегистрированы синхронно, в отличие от остальных. Оба
срабатывают, когда процесс вот-вот затихнет, и асинхронный хук, проигравший гонку
с выходом, убивается раньше, чем успевает записать: у claude -p это наблюдалось
как сессия, навсегда застрявшая в busy.
Хуки одной сессии выполняются параллельно, поэтому весь цикл «прочитать — решить —
записать» идёт под flock на <session_id>.json.lock, а событие старше
сохранённого отвергается. Нужно и то, и другое: одна лишь проверка меток времени
всё ещё позволяет хуку, прочитавшему старое состояние до Stop, записать своё
устаревшее решение после него.
Сабагенты
Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и заканчивает ход, а сессия ждёт результатов, чтобы свести их воедино. Звать вас туда не надо — она занята.
Но Stop при этом приходит раньше, чем сабагенты закончат. Если верить ему
буквально, сессия покажется свободной ровно тогда, когда в неё лезть бессмысленно.
Поэтому сабагенты считаются явно:
| Событие | Что делает |
|---|---|
PreToolUse с матчером ^(Agent|Task)$ |
+1 к счётчику |
SubagentStop |
−1; последний освобождает сессию, если ход уже закончен |
UserPromptSubmit |
сбрасывает счётчик в 0 |
Пока счётчик больше нуля, состояние waiting невозможно: и Stop, и напоминание
idle_prompt дают busy. Освобождает сессию только уход последнего сабагента —
и лишь если основной агент к тому времени остановился.
Матчер якорный не для красоты: это регулярка, и голое Task поймало бы
TaskCreate, TaskUpdate и прочее. Хук проверяет имя инструмента ещё раз, сам.
Сброс на UserPromptSubmit ограничивает ущерб, если сабагент умрёт, не прислав
SubagentStop: счётчик не переживёт следующего вашего сообщения.
Собственные вызовы инструментов сабагентов игнорируются — они долетают до хуков
родителя как PostToolUse с agent_id, но счётчик уже всё сказал, а они добавили
бы только записи на диск. Notification — исключение: она означает, что нужен
человек, и это одинаково верно, в каком бы агенте ни заклинило.
В меню число сабагентов показывается строкой «работает · 3 subagents». В панели — нет: батч из восьми задач остаётся одной строкой «работает 40 мин», и это правильная строка.
zellij
Если сессии живут в табах zellij, меню называет имя таба — это лучший ответ
на «в какой терминал идти», чем путь. Поиск идёт через zellij action dump-layout: рабочий каталог сессии сопоставляется с каталогами пейнов, потому
что в дампе раскладки нет id пейнов и ZELLIJ_PANE_ID для этого не годится.
Отсюда следствия: две сессии в одном табе неразличимы, а сессия, сменившая
cwd после открытия пейна, не найдётся.
Меню ничего не делает — только показывает. Ни строки, ни чипы не кликабельны: переключение таба и подъём окна были написаны и убраны, потому что стоили заметной логики (окна gnome-terminal нельзя сопоставить по pid — все они под одним серверным процессом) ради экономии одного alt-tab.
Если zellij не используется, выключите в настройках: он стоит одного процесса раз в пару минут.
Аварийное завершение
kill, закрытое окно, перезагрузка — SessionEnd не приходит, и файл остаётся.
Разбор завалов проверен на каждом случае отдельно:
| Что осталось | Что с этим происходит |
|---|---|
| файл убитой сессии | процесса нет — файл и его .lock удаляются в пределах 20 с |
| файл из прошлой загрузки | pid сверяется по времени старта процесса, а не только по наличию |
оборванная запись хука (.tmp) |
удаляется, когда старше пяти минут |
незакрытый flock |
ядро снимает блокировку при смерти процесса — тупика не бывает |
Сверка по времени старта — не перестраховка. Файлы состояния переживают
перезагрузку, а pid после неё раздаются заново: проверка «есть ли /proc/<pid>»
отвечает лишь «какой-то процесс с таким номером есть». Без этой сверки сессия,
погибшая в аварии, висела бы в панели вечно, требуя ответа, которого некому дать.
Проверено подстановкой постороннего живого процесса на тот же pid.
Порог в пять минут для .tmp тоже осмысленный: хук может писать такой файл
прямо сейчас, и удаление свежего стоило бы потерянной записи.
Опознание самого процесса claude — отдельная тонкость. Хук запускается как
/bin/sh -c /…/claude-status-hook.py, поэтому в командной строке его родителя
слово «claude» есть, а сам он — не claude и живёт миллисекунды. Поэтому
совпадение ищется по отдельным аргументам (argv[0] называется claude, либо
cli.js внутри пути с claude), а не по строке целиком. Если опознать не
удалось, пишется pid 0.
Найденный pid сверяется на каждом событии, а не только при записи: сессия,
поднятая через --resume, получает новый pid, и без этой сверки файл сохранял бы
старый до ближайшей смены состояния — а читатель, не найдя того процесса, убрал бы
живую сессию из панели.
Если хук вообще не смог опознать процесс claude, он пишет pid 0 — «неизвестно», что никогда не путается с «мёртв»; такие записи истекают по возрасту, через 36 часов.
Известные шероховатости
Stopне отличает «закончил» от «сдался». Оба читаются как «ждёт ввода», и решение для вас в обоих случаях одно и то же.
Тесты
gjs -m tests/test-sessions.js # чтение состояния, порядок, живость, слежение
gjs -m tests/test-abbrev.js # метки чипов: сжатие, коллизии, липкость
gjs -m tests/test-prefs.js # окно настроек строится и находит хуки
tests/test-hook.sh # события -> состояния, блокировка, снос файлов
prefs.js живёт в отдельном процессе, а не в композиторе, поэтому проверка во
вложенном шелле до него не достаёт — без этого теста он единственный файл,
который впервые исполняется у пользователя.
lib/sessions.js намеренно ничего не импортирует из resource:///org/gnome/shell,
а lib/abbrev.js — вообще ничего. Именно это позволяет гонять их вне
композитора — в gjs, а abbrev и в node.
Чтобы посмотреть сырые события хуков, создайте файл-маркер, и каждое событие будет дописываться в него:
touch ~/.local/state/claude-code-status/debug # включить
rm ~/.local/state/claude-code-status/debug # выключить
Он пишет много лишнего о вас. В лог попадают тексты ваших запросов целиком, пути к транскриптам и командные строки шести процессов-предков — включая то, как запущен claude, и адрес сокета zellij. Это диагностический инструмент, а не телеметрия: включайте, когда что-то сломалось, и удаляйте файл после. Рост ограничен восемью мегабайтами, дальше запись прекращается.
Сборка через gnome-extensions pack обязательна: schemas/gschemas.compiled не
хранится в репозитории, и zip, собранный вручную из чекаута, оставит расширение
без схемы настроек.