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.
253 lines
17 KiB
Markdown
253 lines
17 KiB
Markdown
# 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` из хука было бы
|
||
куда дешевле сделать, но форма неверная: сессия, ждущая двадцать минут, должна
|
||
оставаться на виду, а уведомление исчезает в момент, когда его смахнули. Панель
|
||
здесь — единственный канал.
|
||
|
||
## Установка
|
||
|
||
```sh
|
||
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` не отличает «закончил» от «сдался».** Оба читаются как «ждёт ввода»,
|
||
и решение для вас в обоих случаях одно и то же.
|
||
|
||
## Тесты
|
||
|
||
```sh
|
||
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`.
|
||
|
||
Чтобы посмотреть сырые события хуков, создайте файл-маркер, и каждое событие
|
||
будет дописываться в него:
|
||
|
||
```sh
|
||
touch ~/.local/state/claude-code-status/debug
|
||
```
|