kill, a closed window, a reboot: no SessionEnd arrives and the state file stays. Each case was tried rather than reasoned about, and one of the three was broken. A killed session was already handled -- the process is gone, so the file and its lock are removed within the 20 s liveness tick. An interrupted hook write left its temporary file behind forever; those are now swept once they are five minutes old, which is late enough that a hook part-way through writing one does not lose the update. The reboot case was the broken one. State files outlive a reboot and pids are handed out afresh, so "does /proc/<pid> exist" only answers "is some process wearing that number". Verified by giving an unrelated live process the pid of a dead session: the ghost sat in the panel as a session waiting for input, and would have stayed there forever, asking for an answer nobody could give. The pid is now pinned to the process start time from /proc/<pid>/stat, recorded when the state is written and compared when it is read. Files written before that field existed compare only on existence, as before, so a session open across the upgrade is not evicted. An abandoned flock needed nothing: the kernel drops it when the holder dies, so there is no deadlock to recover from.
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, он пишет 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