Files
claude-code-gnome-extension/README.md
T
av d48a18ae75 Ask the session what is running instead of counting it
A session whose main agent had stopped while a background subagent worked
showed as waiting. The count was kept by hand -- +1 on PreToolUse matched
to ^(Agent|Task)$, -1 on SubagentStop -- and the two halves do not see the
same thing. A launch reaches the hook only at the top level: a subagent
spawning its own subagents does it through events carrying agent_id, which
are dropped. SubagentStop arrives for every subagent at every depth. Each
nested one subtracted from a batch it had never joined.

Measured on the live session that showed it: one background agent, then
fourteen nested stops, the first of which took the count to zero and turned
the chip white with the batch still running. Replaying those recorded
events through the old hook reproduces it exactly, and through the new one
holds busy throughout, rising to two while two background agents ran.

The events carry the answer themselves. Stop and SubagentStop -- the two
that can end a turn, and the only two where it matters -- come with
background_tasks: every running task with its type and status. The count is
now read from there and nothing accumulates, so it cannot drift, and a
subagent that dies without sending SubagentStop no longer leaks a count
that pins the chip at busy. Events without the field leave the stored value
alone, which is what keeps an idle_prompt nudge from freeing a working
session.

A background shell is deliberately not counted. A dev server left running
says nothing about whether the session needs you, and treating it as work
would hold the chip at "working" for as long as it lives.

PreToolUse is no longer registered: counting was the only thing it was for.
It stays listed as a legacy event so that both install and uninstall sweep
it out of settings.json rather than leaving it there to spawn the hook on
every agent launch for nothing.
2026-08-23 09:19:30 +03:00

345 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`. По
умолчанию ряд живёт в центральном боксе сразу справа от часов, и без предела
достаточно открытых сессий сдвинули бы часы с центра.
Чипы идут по срочности, а внутри одного состояния первой стоит **самая
давняя**: забывается та, что ждёт дольше всех, а не последняя. Время
показывается у **работающих** чипов — у всех сразу: именно у них счётчик о
чём-то говорит (пять минут — это сборка, сорок — сессия во что-то упёрлась).
У ждущих его нет: их возраст говорит лишь о том, когда вы перестали смотреть,
а это и так видно по самому чипу. Счётчик отключается в настройках
(«Show working time»); в меню возраст показывается у всех строк в любом случае.
## Место в панели
Бокс (левый, центральный, правый) и позиция внутри бокса задаются в настройках и
применяются сразу, без релогина. Индекс 0 — первым в боксе; в центральном 1 —
сразу справа от часов, единственного его обитателя по умолчанию. Индекс больше
числа элементов кладёт ряд в конец, так что «10» — это способ сказать «последним».
Перестановка **пересоздаёт** индикатор, а не двигает актор: `addToStatusArea`
это и есть регистрация под uuid, а публичного вызова «перенести в другой бокс» в
shell нет; всё остальное лезет в приватные боксы `Main.panel`. Стоит это одного
перечитывания нескольких маленьких файлов состояния, и только когда настройку
трогают.
Здесь же вскрылась давняя утечка. `PanelMenu.ButtonBox` в своём `_init` делает
`this.connect('destroy', this._onDestroy.bind(this))`, а его `_onDestroy`
уничтожает `container` — тот самый `St.Bin`, который лежит в боксе панели.
Имя разрешается по цепочке прототипов, поэтому наш метод с тем же именем **молча
подменял** шелловский, и контейнер оставался в панели после каждого выключения
расширения. Наш обработчик теперь называется `_teardown`. Измерено: до
переименования центральный бокс рос на один пустой актор с каждой перестановкой
(2 → 3 → 4 → 5), после — стабильно 2.
## Метки чипов
Имя проекта сжимается до трёх знаков — в настройках можно больше, но не меньше:
три хватает, чтобы развести инициалы, и достаточно узко, чтобы ряд не толкал
часы. Если в имени несколько сегментов (`-`, `_`, 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`. Цифра съедает базу, а не
удлиняет метку, чтобы ряд не расползался.
Два правила держат метки на месте, и без них вся затея рассыпается:
- Назначение идёт **от самой давней сессии**, так что цифру берёт только что
запущенная, а не та, на которую ты сейчас смотришь.
- Выданная метка закреплена за сессией до её конца — даже после того, как
закрылась сессия, из-за которой появилась цифра. Метка, переехавшая под
рукой, хуже метки с цифрой, которая уже не выглядит нужной.
Более широкая метка берёт **больше инициалов**, а не более длинный префикс — по
той же причине. Поэтому `dev-skills` останется `ds` при любой ширине, а
`claude-code-gnome-extension` при четырёх знаках станет `ccge`.
Ширина — единственное, что сбрасывает закреплённые метки: перерисовка, о которой
попросили сами, это не метка, уехавшая под рукой. Смена ширины перелейблит все
сессии разом.
Сокращение отключается в настройках — тогда в чипах полные имена проектов.
В меню строка начинается с той же метки, чтобы соответствие «`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` — если не работают сабагенты (см. ниже) |
| `SubagentStop` | обновляет число работающих сабагентов; последний освобождает сессию |
| `SessionEnd` | файл удаляется |
`PostToolUse` не избыточен: это единственное событие, срабатывающее после выдачи
разрешения, — без него сессия остаётся `blocked` в панели до конца хода. Пишет он
только при реальной смене состояния, так что обычный случай стоит запуска процесса
и нулевого ввода-вывода.
`Stop` и `SessionEnd` зарегистрированы синхронно, в отличие от остальных. Оба
срабатывают, когда процесс вот-вот затихнет, и асинхронный хук, проигравший гонку
с выходом, убивается раньше, чем успевает записать. Наблюдалось это на `claude -p`
— теперь такие запуски вообще не отслеживаются (см. ниже), но гонка та же самая у
любой сессии, закрытой сразу после ответа, и стоила бы навсегда застрявшего `busy`.
Хуки одной сессии выполняются параллельно, поэтому весь цикл «прочитать — решить —
записать» идёт под `flock` на `<session_id>.json.lock`, а событие старше
сохранённого отвергается. Нужно и то, и другое: одна лишь проверка меток времени
всё ещё позволяет хуку, прочитавшему старое состояние до `Stop`, записать своё
устаревшее решение после него.
## Неинтерактивные запуски
`claude -p``--print`) в панель не попадает. Такой запуск печатает один ответ
и завершается: строки ввода у него нет, заблокироваться на вас он не может, идти
к нему некуда. Скрипт из пары десятков таких вызовов превращал бы панель в
мельтешение чипов, исчезающих раньше, чем их успеешь прочесть; так же ведут себя
Agent SDK и интеграции с редакторами.
Флаг ищется в аргументах опознанного процесса claude, по точному совпадению
токена. Аргументы читаются из `/proc/<pid>/cmdline` по разделителю `\0`, а не
разбиением по пробелам: промпт — обычный аргумент, и `claude "когда нужен -p"`
это интерактивная сессия, которая свой чип сохраняет.
Отбрасывание происходит до всякой работы с файлом состояния — headless-сессия не
создаёт его и, соответственно, ничего не удаляет на `SessionEnd`. В отладочный
лог (см. ниже) её события при этом попадают: иначе «почему сессии нет в панели»
было бы нечем объяснить.
## Сабагенты
Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и
**заканчивает ход**, а сессия ждёт результатов, чтобы свести их воедино. Звать
вас туда не надо — она занята.
Но `Stop` при этом приходит раньше, чем сабагенты закончат. Если верить ему
буквально, сессия покажется свободной ровно тогда, когда в неё лезть бессмысленно.
Сколько сабагентов ещё работает, хук не считает, а **берёт из самого события**:
`Stop` и `SubagentStop` несут поле `background_tasks` — список фоновых задач с их
`status`. Оттуда и берётся число: задачи с `type: "subagent"` и `status:
"running"`. Остальные события этого поля не несут, и тогда стоит последнее
известное значение.
Пока число больше нуля, состояние `waiting` невозможно: и `Stop`, и напоминание
`idle_prompt` дают `busy`. Освобождает сессию только `SubagentStop`, пришедший в
момент, когда список пуст, — и лишь если основной агент к тому времени
остановился.
Фоновая команда (`type: "bash"`) сабагентом не считается намеренно: поднятый
dev-сервер живёт часами и ничего не говорит о том, нужны вы сессии или нет, — а
приравняв его к работе, чип пришлось бы держать «работает» всё это время.
### Почему не счётчик
Сначала счётчик и был: `+1` на `PreToolUse` с матчером `^(Agent|Task)$`, `1` на
`SubagentStop`. Он ошибался в худшую сторону — гасил `busy` у занятой сессии, —
и вот почему. Запуск сабагента виден хуку только на верхнем уровне: когда свой
сабагент разворачивает сабагент, событие приходит с `agent_id` и отбрасывается.
А `SubagentStop` приходит **за каждого сабагента на любой глубине**. Каждый
вложенный вычитал единицу из батча, в который никогда не входил.
Замерено на живой сессии: один фоновый агент, четырнадцать вложенных `SubagentStop`
подряд — счётчик обнулился на первом же, и сессия с работающим батчем показалась
ждущей. Снимок вычитать нечего: он просто говорит, что запущено сейчас, и по той
же причине не течёт, если сабагент умрёт, не прислав `SubagentStop`.
Собственные вызовы инструментов сабагентов игнорируются — они долетают до хуков
родителя как `PostToolUse` с `agent_id`, но сессия занята и без них, а записей на
диск от одного сабагента набежали бы сотни. `Notification` — исключение: она
означает, что нужен человек, и это одинаково верно, в каком бы агенте ни заклинило.
В меню число сабагентов показывается строкой «работает · 3 subagents». В панели —
нет: батч из восьми задач остаётся одной строкой «работает 40 мин», и это
правильная строка. Считаются фоновые: батч, которого основной агент дожидается
сам, и так виден по состоянию `busy`.
## 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` не отличает «закончил» от «сдался».** Оба читаются как «ждёт ввода»,
и решение для вас в обоих случаях одно и то же.
## Тесты
```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 # включить
rm ~/.local/state/claude-code-status/debug # выключить
```
**Он пишет много лишнего о вас.** В лог попадают тексты ваших запросов целиком,
пути к транскриптам и командные строки шести процессов-предков — включая то, как
запущен claude, и адрес сокета zellij. Это диагностический инструмент, а не
телеметрия: включайте, когда что-то сломалось, и удаляйте файл после. Рост
ограничен восемью мегабайтами, дальше запись прекращается.
Сборка через `gnome-extensions pack` обязательна: `schemas/gschemas.compiled` не
хранится в репозитории, и zip, собранный вручную из чекаута, оставит расширение
без схемы настроек.