Compare commits
8
Commits
97783d43b8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48a18ae75
|
||
|
|
f8e85d5703
|
||
|
|
be69bec17f
|
||
|
|
67d7b14cf5
|
||
|
|
2565d45bb5
|
||
|
|
f4a06cdc44
|
||
|
|
75bfe77950
|
||
|
|
ca42d69704
|
@@ -0,0 +1,47 @@
|
|||||||
|
# Работа над этим расширением
|
||||||
|
|
||||||
|
## Никогда не писать в dconf живой сессии
|
||||||
|
|
||||||
|
**Запрещено** выполнять `gsettings set`, `gsettings reset`, `dconf write` и `dconf reset`
|
||||||
|
из bash этой сессии — ни голыми, ни с префиксом `XDG_CONFIG_HOME=...`.
|
||||||
|
|
||||||
|
Почему префикс не помогает: базу выбирает не клиент, а `dconf-service`, поднятый шиной
|
||||||
|
живой сессии, и берёт он её из **своего** окружения. Любая запись уходит в
|
||||||
|
`~/.config/dconf/user` Антона.
|
||||||
|
|
||||||
|
Чем это кончалось (дважды): затёрт `org.gnome.shell enabled-extensions` — во второй раз,
|
||||||
|
9 и 10 августа 2026, на работающем shell, отчего у него мгновенно отвалились dock, ding,
|
||||||
|
appindicators и tiling-assistant. Прежний список dconf не хранит, восстановить его точно
|
||||||
|
нельзя, только реконструировать.
|
||||||
|
|
||||||
|
Что делать вместо:
|
||||||
|
|
||||||
|
- нужно поменять настройку в **живой** сессии — отдать Антону команду, он выполнит её сам
|
||||||
|
через `! <команда>`;
|
||||||
|
- нужна настройка для **вложенного** shell — экспортировать `XDG_CONFIG_HOME` до форка
|
||||||
|
шины и звать `gsettings` уже внутри этой шины (см. рецепт ниже);
|
||||||
|
- читать (`dconf read`, `gsettings get`) можно свободно.
|
||||||
|
|
||||||
|
## Проверка изменений — только во вложенном shell
|
||||||
|
|
||||||
|
Живую сессию не трогаем вообще: расширение включает и перезагружает shell Антон сам.
|
||||||
|
У него несколько сессий Claude Code в zellij внутри gnome-terminal, и падение
|
||||||
|
gnome-shell унесёт весь его рабочий набор.
|
||||||
|
|
||||||
|
Рецепт для проверок только на чтение (GNOME 46, проверен 10.08.2026):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
XDG_STATE_HOME=$TMP/state timeout 50 dbus-run-session -- \
|
||||||
|
gnome-shell --nested --wayland > $TMP/nested.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Подставные файлы сессий обязаны нести `event_ts` (плюс `since`, `started`): без него
|
||||||
|
`SessionStore` считает их древними и **удаляет** до первой отрисовки.
|
||||||
|
|
||||||
|
Скриншот из вложенного shell снять нельзя. Чтобы увидеть, что он нарисовал, добавляем
|
||||||
|
временный `log()` в код, читаем его из `nested.log` — и убираем инструментацию перед
|
||||||
|
коммитом.
|
||||||
|
|
||||||
|
## Коммиты
|
||||||
|
|
||||||
|
Без трейлера `Co-Authored-By`.
|
||||||
@@ -42,15 +42,46 @@ Code ждёт меня прямо сейчас?**
|
|||||||
текстом `«Claude needs your permission»`. Различить их можно было бы только через
|
текстом `«Claude needs your permission»`. Различить их можно было бы только через
|
||||||
`PreToolUse` — ценой записи файла на каждый вызов инструмента.
|
`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) — инициалы, иначе первые буквы.
|
три хватает, чтобы развести инициалы, и достаточно узко, чтобы ряд не толкал
|
||||||
|
часы. Если в имени несколько сегментов (`-`, `_`, camelCase) — инициалы, иначе
|
||||||
|
первые буквы.
|
||||||
|
|
||||||
| Проект | Чип |
|
| Проект | Чип |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -76,6 +107,14 @@ Code ждёт меня прямо сейчас?**
|
|||||||
закрылась сессия, из-за которой появилась цифра. Метка, переехавшая под
|
закрылась сессия, из-за которой появилась цифра. Метка, переехавшая под
|
||||||
рукой, хуже метки с цифрой, которая уже не выглядит нужной.
|
рукой, хуже метки с цифрой, которая уже не выглядит нужной.
|
||||||
|
|
||||||
|
Более широкая метка берёт **больше инициалов**, а не более длинный префикс — по
|
||||||
|
той же причине. Поэтому `dev-skills` останется `ds` при любой ширине, а
|
||||||
|
`claude-code-gnome-extension` при четырёх знаках станет `ccge`.
|
||||||
|
|
||||||
|
Ширина — единственное, что сбрасывает закреплённые метки: перерисовка, о которой
|
||||||
|
попросили сами, это не метка, уехавшая под рукой. Смена ширины перелейблит все
|
||||||
|
сессии разом.
|
||||||
|
|
||||||
Сокращение отключается в настройках — тогда в чипах полные имена проектов.
|
Сокращение отключается в настройках — тогда в чипах полные имена проектов.
|
||||||
В меню строка начинается с той же метки, чтобы соответствие «`ds` — это
|
В меню строка начинается с той же метки, чтобы соответствие «`ds` — это
|
||||||
dev-skills» читалось, а не угадывалось.
|
dev-skills» читалось, а не угадывалось.
|
||||||
@@ -123,7 +162,8 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
|
|||||||
| `PostToolUse` | `busy` |
|
| `PostToolUse` | `busy` |
|
||||||
| `PreCompact` | `busy` |
|
| `PreCompact` | `busy` |
|
||||||
| `Notification` | `blocked` или `waiting`, в зависимости от `notification_type` |
|
| `Notification` | `blocked` или `waiting`, в зависимости от `notification_type` |
|
||||||
| `Stop` | `waiting` |
|
| `Stop` | `waiting` — если не работают сабагенты (см. ниже) |
|
||||||
|
| `SubagentStop` | обновляет число работающих сабагентов; последний освобождает сессию |
|
||||||
| `SessionEnd` | файл удаляется |
|
| `SessionEnd` | файл удаляется |
|
||||||
|
|
||||||
`PostToolUse` не избыточен: это единственное событие, срабатывающее после выдачи
|
`PostToolUse` не избыточен: это единственное событие, срабатывающее после выдачи
|
||||||
@@ -133,8 +173,9 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
|
|||||||
|
|
||||||
`Stop` и `SessionEnd` зарегистрированы синхронно, в отличие от остальных. Оба
|
`Stop` и `SessionEnd` зарегистрированы синхронно, в отличие от остальных. Оба
|
||||||
срабатывают, когда процесс вот-вот затихнет, и асинхронный хук, проигравший гонку
|
срабатывают, когда процесс вот-вот затихнет, и асинхронный хук, проигравший гонку
|
||||||
с выходом, убивается раньше, чем успевает записать: у `claude -p` это наблюдалось
|
с выходом, убивается раньше, чем успевает записать. Наблюдалось это на `claude -p`
|
||||||
как сессия, навсегда застрявшая в `busy`.
|
— теперь такие запуски вообще не отслеживаются (см. ниже), но гонка та же самая у
|
||||||
|
любой сессии, закрытой сразу после ответа, и стоила бы навсегда застрявшего `busy`.
|
||||||
|
|
||||||
Хуки одной сессии выполняются параллельно, поэтому весь цикл «прочитать — решить —
|
Хуки одной сессии выполняются параллельно, поэтому весь цикл «прочитать — решить —
|
||||||
записать» идёт под `flock` на `<session_id>.json.lock`, а событие старше
|
записать» идёт под `flock` на `<session_id>.json.lock`, а событие старше
|
||||||
@@ -142,6 +183,24 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
|
|||||||
всё ещё позволяет хуку, прочитавшему старое состояние до `Stop`, записать своё
|
всё ещё позволяет хуку, прочитавшему старое состояние до `Stop`, записать своё
|
||||||
устаревшее решение после него.
|
устаревшее решение после него.
|
||||||
|
|
||||||
|
## Неинтерактивные запуски
|
||||||
|
|
||||||
|
`claude -p` (и `--print`) в панель не попадает. Такой запуск печатает один ответ
|
||||||
|
и завершается: строки ввода у него нет, заблокироваться на вас он не может, идти
|
||||||
|
к нему некуда. Скрипт из пары десятков таких вызовов превращал бы панель в
|
||||||
|
мельтешение чипов, исчезающих раньше, чем их успеешь прочесть; так же ведут себя
|
||||||
|
Agent SDK и интеграции с редакторами.
|
||||||
|
|
||||||
|
Флаг ищется в аргументах опознанного процесса claude, по точному совпадению
|
||||||
|
токена. Аргументы читаются из `/proc/<pid>/cmdline` по разделителю `\0`, а не
|
||||||
|
разбиением по пробелам: промпт — обычный аргумент, и `claude "когда нужен -p"` —
|
||||||
|
это интерактивная сессия, которая свой чип сохраняет.
|
||||||
|
|
||||||
|
Отбрасывание происходит до всякой работы с файлом состояния — headless-сессия не
|
||||||
|
создаёт его и, соответственно, ничего не удаляет на `SessionEnd`. В отладочный
|
||||||
|
лог (см. ниже) её события при этом попадают: иначе «почему сессии нет в панели»
|
||||||
|
было бы нечем объяснить.
|
||||||
|
|
||||||
## Сабагенты
|
## Сабагенты
|
||||||
|
|
||||||
Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и
|
Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и
|
||||||
@@ -150,59 +209,104 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
|
|||||||
|
|
||||||
Но `Stop` при этом приходит раньше, чем сабагенты закончат. Если верить ему
|
Но `Stop` при этом приходит раньше, чем сабагенты закончат. Если верить ему
|
||||||
буквально, сессия покажется свободной ровно тогда, когда в неё лезть бессмысленно.
|
буквально, сессия покажется свободной ровно тогда, когда в неё лезть бессмысленно.
|
||||||
Поэтому сабагенты считаются явно:
|
|
||||||
|
|
||||||
| Событие | Что делает |
|
Сколько сабагентов ещё работает, хук не считает, а **берёт из самого события**:
|
||||||
|---|---|
|
`Stop` и `SubagentStop` несут поле `background_tasks` — список фоновых задач с их
|
||||||
| `PreToolUse` с матчером `^(Agent\|Task)$` | +1 к счётчику |
|
`status`. Оттуда и берётся число: задачи с `type: "subagent"` и `status:
|
||||||
| `SubagentStop` | −1; последний освобождает сессию, если ход уже закончен |
|
"running"`. Остальные события этого поля не несут, и тогда стоит последнее
|
||||||
| `UserPromptSubmit` | сбрасывает счётчик в 0 |
|
известное значение.
|
||||||
|
|
||||||
Пока счётчик больше нуля, состояние `waiting` невозможно: и `Stop`, и напоминание
|
Пока число больше нуля, состояние `waiting` невозможно: и `Stop`, и напоминание
|
||||||
`idle_prompt` дают `busy`. Освобождает сессию только уход последнего сабагента —
|
`idle_prompt` дают `busy`. Освобождает сессию только `SubagentStop`, пришедший в
|
||||||
и лишь если основной агент к тому времени остановился.
|
момент, когда список пуст, — и лишь если основной агент к тому времени
|
||||||
|
остановился.
|
||||||
|
|
||||||
Матчер якорный не для красоты: это регулярка, и голое `Task` поймало бы
|
Фоновая команда (`type: "bash"`) сабагентом не считается намеренно: поднятый
|
||||||
`TaskCreate`, `TaskUpdate` и прочее. Хук проверяет имя инструмента ещё раз, сам.
|
dev-сервер живёт часами и ничего не говорит о том, нужны вы сессии или нет, — а
|
||||||
Сброс на `UserPromptSubmit` ограничивает ущерб, если сабагент умрёт, не прислав
|
приравняв его к работе, чип пришлось бы держать «работает» всё это время.
|
||||||
`SubagentStop`: счётчик не переживёт следующего вашего сообщения.
|
|
||||||
|
### Почему не счётчик
|
||||||
|
|
||||||
|
Сначала счётчик и был: `+1` на `PreToolUse` с матчером `^(Agent|Task)$`, `−1` на
|
||||||
|
`SubagentStop`. Он ошибался в худшую сторону — гасил `busy` у занятой сессии, —
|
||||||
|
и вот почему. Запуск сабагента виден хуку только на верхнем уровне: когда свой
|
||||||
|
сабагент разворачивает сабагент, событие приходит с `agent_id` и отбрасывается.
|
||||||
|
А `SubagentStop` приходит **за каждого сабагента на любой глубине**. Каждый
|
||||||
|
вложенный вычитал единицу из батча, в который никогда не входил.
|
||||||
|
|
||||||
|
Замерено на живой сессии: один фоновый агент, четырнадцать вложенных `SubagentStop`
|
||||||
|
подряд — счётчик обнулился на первом же, и сессия с работающим батчем показалась
|
||||||
|
ждущей. Снимок вычитать нечего: он просто говорит, что запущено сейчас, и по той
|
||||||
|
же причине не течёт, если сабагент умрёт, не прислав `SubagentStop`.
|
||||||
|
|
||||||
Собственные вызовы инструментов сабагентов игнорируются — они долетают до хуков
|
Собственные вызовы инструментов сабагентов игнорируются — они долетают до хуков
|
||||||
родителя как `PostToolUse` с `agent_id`, но счётчик уже всё сказал, а они добавили
|
родителя как `PostToolUse` с `agent_id`, но сессия занята и без них, а записей на
|
||||||
бы только записи на диск. `Notification` — исключение: она означает, что нужен
|
диск от одного сабагента набежали бы сотни. `Notification` — исключение: она
|
||||||
человек, и это одинаково верно, в каком бы агенте ни заклинило.
|
означает, что нужен человек, и это одинаково верно, в каком бы агенте ни заклинило.
|
||||||
|
|
||||||
В меню число сабагентов показывается строкой «работает · 3 subagents». В панели —
|
В меню число сабагентов показывается строкой «работает · 3 subagents». В панели —
|
||||||
нет: батч из восьми задач остаётся одной строкой «работает 40 мин», и это
|
нет: батч из восьми задач остаётся одной строкой «работает 40 мин», и это
|
||||||
правильная строка.
|
правильная строка. Считаются фоновые: батч, которого основной агент дожидается
|
||||||
|
сам, и так виден по состоянию `busy`.
|
||||||
|
|
||||||
## zellij
|
## zellij
|
||||||
|
|
||||||
Если сессии живут в табах zellij, меню показывает **имя таба**, а клик
|
Если сессии живут в табах zellij, меню называет **имя таба** — это лучший ответ
|
||||||
переключает на него. Поиск идёт через `zellij action dump-layout` — рабочий
|
на «в какой терминал идти», чем путь. Поиск идёт через `zellij action
|
||||||
каталог сессии сопоставляется с каталогами пейнов, потому что в дампе раскладки
|
dump-layout`: рабочий каталог сессии сопоставляется с каталогами пейнов, потому
|
||||||
нет id пейнов и `ZELLIJ_PANE_ID` для этого не годится. Отсюда следствия: две
|
что в дампе раскладки нет id пейнов и `ZELLIJ_PANE_ID` для этого не годится.
|
||||||
сессии в одном табе неразличимы, а сессия, сменившая `cwd` после открытия пейна,
|
Отсюда следствия: две сессии в одном табе неразличимы, а сессия, сменившая
|
||||||
не найдётся. Строки, которые не разрешились, остаются некликабельными — вместо
|
`cwd` после открытия пейна, не найдётся.
|
||||||
того чтобы делать вид, будто клик что-то делает.
|
|
||||||
|
Меню **ничего не делает** — только показывает. Ни строки, ни чипы не кликабельны:
|
||||||
|
переключение таба и подъём окна были написаны и убраны, потому что стоили заметной
|
||||||
|
логики (окна gnome-terminal нельзя сопоставить по pid — все они под одним
|
||||||
|
серверным процессом) ради экономии одного alt-tab.
|
||||||
|
|
||||||
Если zellij не используется, выключите в настройках: он стоит одного процесса
|
Если 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 часов.
|
||||||
|
|
||||||
## Известные шероховатости
|
## Известные шероховатости
|
||||||
|
|
||||||
- **Протухшие сессии.** Убитый терминал не присылает `SessionEnd`. Живость
|
|
||||||
перепроверяется каждые 20 с по `/proc/<pid>`, файл удаляется — убитая сессия
|
|
||||||
исчезает в пределах этого окна, а не висит вечно. Если хук вообще не смог
|
|
||||||
опознать процесс claude, он записывает pid 0 — «неизвестно», что никогда не
|
|
||||||
путается с «мёртв», — и такие записи истекают по возрасту, через 36 часов.
|
|
||||||
- **`Stop` не отличает «закончил» от «сдался».** Оба читаются как «ждёт ввода»,
|
- **`Stop` не отличает «закончил» от «сдался».** Оба читаются как «ждёт ввода»,
|
||||||
и решение для вас в обоих случаях одно и то же.
|
и решение для вас в обоих случаях одно и то же.
|
||||||
- **Фокус ведёт к табу zellij, а не к окну.** gnome-terminal держит все окна под
|
|
||||||
одним общим серверным процессом, так что окно нельзя сопоставить по pid. Окно
|
|
||||||
поднимается, только если в его заголовке есть имя zellij-сессии; когда
|
|
||||||
совпадения нет, таб всё равно переключается, а фокус остаётся на месте —
|
|
||||||
поднять произвольный терминал хуже, чем не поднимать никакой.
|
|
||||||
|
|
||||||
## Тесты
|
## Тесты
|
||||||
|
|
||||||
@@ -225,5 +329,16 @@ tests/test-hook.sh # события -> состояния, бло
|
|||||||
будет дописываться в него:
|
будет дописываться в него:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
touch ~/.local/state/claude-code-status/debug
|
touch ~/.local/state/claude-code-status/debug # включить
|
||||||
|
rm ~/.local/state/claude-code-status/debug # выключить
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Он пишет много лишнего о вас.** В лог попадают тексты ваших запросов целиком,
|
||||||
|
пути к транскриптам и командные строки шести процессов-предков — включая то, как
|
||||||
|
запущен claude, и адрес сокета zellij. Это диагностический инструмент, а не
|
||||||
|
телеметрия: включайте, когда что-то сломалось, и удаляйте файл после. Рост
|
||||||
|
ограничен восемью мегабайтами, дальше запись прекращается.
|
||||||
|
|
||||||
|
Сборка через `gnome-extensions pack` обязательна: `schemas/gschemas.compiled` не
|
||||||
|
хранится в репозитории, и zip, собранный вручную из чекаута, оставит расширение
|
||||||
|
без схемы настроек.
|
||||||
|
|||||||
+40
-6
@@ -5,16 +5,50 @@ import { ClaudeStatusIndicator } from './lib/indicator.js';
|
|||||||
|
|
||||||
export default class ClaudeCodeStatusExtension extends Extension {
|
export default class ClaudeCodeStatusExtension extends Extension {
|
||||||
enable() {
|
enable() {
|
||||||
this._indicator = new ClaudeStatusIndicator(this);
|
this._settings = this.getSettings();
|
||||||
// Centre box, index 1: immediately right of the clock, which is the
|
this._place();
|
||||||
// centre box's only occupant by default. The status area on the right
|
// Placement is applied by rebuilding rather than by moving the actor.
|
||||||
// is where you look for the system's own state; sessions belong next
|
// addToStatusArea is what registers the indicator under this uuid and
|
||||||
// to the thing you already glance at.
|
// there is no documented call to move one between panel boxes; the
|
||||||
Main.panel.addToStatusArea(this.uuid, this._indicator, 1, 'center');
|
// alternatives all reach into Main.panel's private boxes. Rebuilding
|
||||||
|
// costs one re-read of a handful of small state files, and only when
|
||||||
|
// the setting is touched.
|
||||||
|
this._placementId = this._settings.connect('changed::panel-box',
|
||||||
|
() => this._replace());
|
||||||
|
this._positionId = this._settings.connect('changed::panel-position',
|
||||||
|
() => this._replace());
|
||||||
}
|
}
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
|
for (const id of [this._placementId, this._positionId]) {
|
||||||
|
if (id)
|
||||||
|
this._settings.disconnect(id);
|
||||||
|
}
|
||||||
|
this._placementId = 0;
|
||||||
|
this._positionId = 0;
|
||||||
this._indicator?.destroy();
|
this._indicator?.destroy();
|
||||||
this._indicator = null;
|
this._indicator = null;
|
||||||
|
this._settings = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_place() {
|
||||||
|
this._indicator = new ClaudeStatusIndicator(this);
|
||||||
|
// Centre box, index 1 by default: immediately right of the clock,
|
||||||
|
// which is the centre box's only occupant. The status area on the
|
||||||
|
// right is where you look for the system's own state; sessions belong
|
||||||
|
// next to the thing you already glance at. An index past the end of
|
||||||
|
// the box lands at the end, so a large one is a way of saying "last".
|
||||||
|
Main.panel.addToStatusArea(this.uuid, this._indicator,
|
||||||
|
this._settings.get_int('panel-position'),
|
||||||
|
this._settings.get_string('panel-box'));
|
||||||
|
}
|
||||||
|
|
||||||
|
_replace() {
|
||||||
|
// The indicator's own destroy handler is what unregisters it from the
|
||||||
|
// status area, so this must happen before the next addToStatusArea --
|
||||||
|
// that call throws on a uuid that is still registered.
|
||||||
|
this._indicator?.destroy();
|
||||||
|
this._indicator = null;
|
||||||
|
this._place();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+280
-65
@@ -21,6 +21,7 @@ Design notes that are easy to get wrong:
|
|||||||
* No stdlib import beyond what is needed: this runs once per tool call.
|
* No stdlib import beyond what is needed: this runs once per tool call.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import errno
|
||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -47,10 +48,45 @@ NOTIFICATION_STATES = {
|
|||||||
"agent_completed": "waiting",
|
"agent_completed": "waiting",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tools that spawn a subagent. Matched again here, not just in the hook
|
# Background tasks that mean the session is still working. A background shell
|
||||||
# registration: the matcher is a regex and a mistake there would silently
|
# is not one: a dev server left running says nothing about whether the session
|
||||||
# inflate the count with TaskCreate, TaskUpdate and the like.
|
# needs you, and counting it would pin the chip at "working" for as long as it
|
||||||
AGENT_TOOLS = {"Agent", "Task"}
|
# lives.
|
||||||
|
BUSY_TASK_TYPES = {"subagent"}
|
||||||
|
|
||||||
|
# Flags that mean "run one prompt and exit" rather than "open a session". See
|
||||||
|
# is_headless: such a run has no terminal to be called over to.
|
||||||
|
HEADLESS_FLAGS = {"-p", "--print"}
|
||||||
|
|
||||||
|
# Events that mean the question has been dealt with. Anything else leaves a
|
||||||
|
# "blocked" session blocked: a subagent finishing, or the next tool starting,
|
||||||
|
# says nothing about the prompt still sitting on your screen, and clearing it
|
||||||
|
# would hide the one state this indicator exists to surface.
|
||||||
|
BLOCK_CLEARING = {"PostToolUse", "UserPromptSubmit", "Stop", "SessionStart"}
|
||||||
|
|
||||||
|
# Nothing here is displayed at full length, and both ends of the pipe have to
|
||||||
|
# survive a hostile or merely absurd value: a multi-megabyte cwd would be copied
|
||||||
|
# into the state file, read back by the compositor and handed to Pango.
|
||||||
|
MAX_TEXT = 512
|
||||||
|
# A stored timestamp further ahead than this is not a concurrent write, it is
|
||||||
|
# corruption -- and left alone it would refuse every later event forever,
|
||||||
|
# freezing the session's displayed state for good.
|
||||||
|
FUTURE_SLACK = 60 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def number(value, default=0):
|
||||||
|
"""Coerce a value read back from disk. Files are ordinary user-writable
|
||||||
|
JSON: one corrupt field must not take the hook down for every session."""
|
||||||
|
try:
|
||||||
|
n = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return default if n != n or n in (float("inf"), float("-inf")) else n
|
||||||
|
|
||||||
|
|
||||||
|
def clip(value):
|
||||||
|
text = value if isinstance(value, str) else ""
|
||||||
|
return text[:MAX_TEXT]
|
||||||
|
|
||||||
EVENT_STATES = {
|
EVENT_STATES = {
|
||||||
# A session that has just opened is waiting for your first prompt, which is
|
# A session that has just opened is waiting for your first prompt, which is
|
||||||
@@ -74,11 +110,23 @@ def debug_log(event):
|
|||||||
environment, which cannot be changed without restarting the session.
|
environment, which cannot be changed without restarting the session.
|
||||||
"""
|
"""
|
||||||
marker = os.path.join(STATE_DIR, "debug")
|
marker = os.path.join(STATE_DIR, "debug")
|
||||||
if not os.path.exists(marker):
|
try:
|
||||||
|
# Not followed through a symlink, and not grown without limit: this
|
||||||
|
# records prompts verbatim and the command lines of ancestor processes.
|
||||||
|
if os.lstat(marker).st_size > 8 << 20:
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
with open(marker, "a") as fh:
|
fd = os.open(marker, os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW)
|
||||||
stamped = dict(event, _at=time.strftime("%H:%M:%S"))
|
with os.fdopen(fd, "a") as fh:
|
||||||
|
chain, pid = [], os.getppid()
|
||||||
|
for _ in range(6):
|
||||||
|
if pid <= 1:
|
||||||
|
break
|
||||||
|
chain.append("%d %s" % (pid, read_cmdline(pid)[:90]))
|
||||||
|
pid = parent_of(pid)
|
||||||
|
stamped = dict(event, _at=time.strftime("%H:%M:%S"), _ancestry=chain)
|
||||||
fh.write(json.dumps(stamped, sort_keys=True)[:2000] + "\n")
|
fh.write(json.dumps(stamped, sort_keys=True)[:2000] + "\n")
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
@@ -93,22 +141,51 @@ def derive_state(event):
|
|||||||
# and that is just as true when the agent that got stuck is a subagent.
|
# and that is just as true when the agent that got stuck is a subagent.
|
||||||
if name == "Notification":
|
if name == "Notification":
|
||||||
return NOTIFICATION_STATES.get(event.get("notification_type"))
|
return NOTIFICATION_STATES.get(event.get("notification_type"))
|
||||||
# SubagentStop is the counter's decrement and carries agent_id itself, so
|
# SubagentStop carries agent_id itself, so it has to pass the filter below.
|
||||||
# it has to pass the filter below. Its state is decided in apply_event,
|
# It is the one event that can free a session whose main agent stopped a
|
||||||
# which is where the count is known.
|
# long time ago; which way it goes needs the snapshot and is decided in
|
||||||
|
# apply_event.
|
||||||
if name == "SubagentStop":
|
if name == "SubagentStop":
|
||||||
return "busy"
|
return "busy"
|
||||||
# A subagent's own tool calls also reach the parent session's hooks
|
# A subagent's own tool calls also reach the parent session's hooks
|
||||||
# (measured: PostToolUse carrying agent_id and agent_type). They are
|
# (measured: PostToolUse carrying agent_id and agent_type). They are
|
||||||
# ignored, because the count already says a subagent is running and these
|
# ignored: the session is working either way, and a single subagent's shell
|
||||||
# would only add write traffic.
|
# commands alone would be hundreds of writes.
|
||||||
if event.get("agent_id"):
|
if event.get("agent_id"):
|
||||||
return None
|
return None
|
||||||
if name == "PreToolUse" and event.get("tool_name") not in AGENT_TOOLS:
|
|
||||||
return None
|
|
||||||
return EVENT_STATES.get(name)
|
return EVENT_STATES.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def running_agents(event):
|
||||||
|
"""How many subagents the session still has running, or None if this event
|
||||||
|
does not say.
|
||||||
|
|
||||||
|
Read from the event's own ``background_tasks`` rather than counted from
|
||||||
|
starts and stops. Counting was what this did first, and it was wrong in the
|
||||||
|
direction that matters: ``PreToolUse`` only ever sees a top-level launch --
|
||||||
|
a subagent spawning its own subagents does it through hooks carrying
|
||||||
|
``agent_id``, which are dropped above -- while ``SubagentStop`` arrives for
|
||||||
|
every subagent at every depth. Each nested one therefore subtracted from a
|
||||||
|
batch it had never joined, and the session was declared free with its work
|
||||||
|
still running. Measured live: one background agent, fourteen nested stops
|
||||||
|
behind it, and a session shown as waiting from the first of them on.
|
||||||
|
|
||||||
|
The field is present exactly where it decides something: on ``Stop`` and
|
||||||
|
``SubagentStop``, the two events that can end a turn. Everywhere else it is
|
||||||
|
absent, and the stored value stands.
|
||||||
|
"""
|
||||||
|
tasks = event.get("background_tasks")
|
||||||
|
if not isinstance(tasks, list):
|
||||||
|
return None
|
||||||
|
running = 0
|
||||||
|
for task in tasks:
|
||||||
|
if not isinstance(task, dict):
|
||||||
|
continue
|
||||||
|
if task.get("type") in BUSY_TASK_TYPES and task.get("status") == "running":
|
||||||
|
running += 1
|
||||||
|
return min(running, 999)
|
||||||
|
|
||||||
|
|
||||||
def read_environ(pid):
|
def read_environ(pid):
|
||||||
"""Environment of a process as a dict, empty if it is gone or not ours."""
|
"""Environment of a process as a dict, empty if it is gone or not ours."""
|
||||||
try:
|
try:
|
||||||
@@ -126,12 +203,23 @@ def read_environ(pid):
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
def read_cmdline(pid):
|
def read_argv(pid):
|
||||||
|
"""Command line of a process as a list of arguments, empty if it is gone.
|
||||||
|
|
||||||
|
Split on the NUL separators the kernel actually puts there, not on spaces:
|
||||||
|
an argument may contain spaces of its own, and `claude "when to use -p"`
|
||||||
|
must not read as an argument list containing a bare "-p".
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
with open("/proc/%d/cmdline" % pid, "rb") as fh:
|
with open("/proc/%d/cmdline" % pid, "rb") as fh:
|
||||||
return fh.read().replace(b"\0", b" ").decode("utf-8", "replace")
|
raw = fh.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return ""
|
return []
|
||||||
|
return [arg.decode("utf-8", "replace") for arg in raw.split(b"\0") if arg]
|
||||||
|
|
||||||
|
|
||||||
|
def read_cmdline(pid):
|
||||||
|
return " ".join(read_argv(pid))
|
||||||
|
|
||||||
|
|
||||||
def parent_of(pid):
|
def parent_of(pid):
|
||||||
@@ -145,32 +233,93 @@ def parent_of(pid):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def find_claude_pid():
|
def looks_like_claude(argv):
|
||||||
"""Nearest ancestor that is the claude process itself, or 0 if unknown.
|
"""Is this argument list the claude binary itself?
|
||||||
|
|
||||||
The hook is spawned through a shell, so the immediate parent is usually not
|
Matched per argument, never against the joined string. The hook is spawned
|
||||||
claude. Walking beyond a handful of levels risks latching onto an outer
|
as `/bin/sh -c /.../claude-status-hook.py`, so its parent's command line
|
||||||
claude when one session drives another, so the search stops early.
|
contains the word "claude" -- in a path -- without being claude at all.
|
||||||
|
Latching onto that shell records a pid that exits milliseconds later, and
|
||||||
|
the session then flickers in and out of the panel.
|
||||||
|
"""
|
||||||
|
for token in argv:
|
||||||
|
base = os.path.basename(token)
|
||||||
|
if base == "claude":
|
||||||
|
return True
|
||||||
|
# npm-style install: node /path/to/claude-code/cli.js
|
||||||
|
if base == "cli.js" and "claude" in token:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_headless(argv):
|
||||||
|
"""Was this claude started to print one answer and exit?
|
||||||
|
|
||||||
|
`claude -p` has no input line and nobody sitting in front of it, so it can
|
||||||
|
never be blocked on you and there is nothing to walk over to. Left in, a
|
||||||
|
script that runs a few dozen of them turns the panel into a flicker of chips
|
||||||
|
that are gone before they can be read -- and the same goes for the Agent SDK
|
||||||
|
and editor integrations, which drive claude the same way.
|
||||||
|
|
||||||
|
Only exact tokens count. A prompt is an ordinary argument, and `claude "what
|
||||||
|
does -p do"` is an interactive session that must keep its chip.
|
||||||
|
"""
|
||||||
|
return any(token in HEADLESS_FLAGS for token in argv)
|
||||||
|
|
||||||
|
|
||||||
|
def find_claude():
|
||||||
|
"""Nearest ancestor that is the claude process itself, with its arguments.
|
||||||
|
|
||||||
|
Returns (pid, argv), or (0, []) if it cannot be identified. The hook is
|
||||||
|
spawned through a shell, so the immediate parent is not claude. Walking
|
||||||
|
beyond a handful of levels risks latching onto an outer claude when one
|
||||||
|
session drives another, so the search stops early.
|
||||||
|
|
||||||
Returning 0 rather than guessing matters: the reader deletes state files
|
Returning 0 rather than guessing matters: the reader deletes state files
|
||||||
whose pid is gone, and the obvious fallback -- the shell that spawned this
|
whose process is gone, and the obvious fallback -- the shell that spawned
|
||||||
hook -- exits milliseconds later, which would make the session flicker in
|
this hook -- exits immediately.
|
||||||
and out of the panel forever.
|
|
||||||
"""
|
"""
|
||||||
pid = os.getppid()
|
pid = os.getppid()
|
||||||
for _ in range(6):
|
for _ in range(6):
|
||||||
if pid <= 1:
|
if pid <= 1:
|
||||||
break
|
break
|
||||||
# Matched anywhere in the command line, not just argv[0]: installs that
|
argv = read_argv(pid)
|
||||||
# run it as `node .../claude/cli.js` are just as valid as a direct one.
|
if looks_like_claude(argv):
|
||||||
if "claude" in read_cmdline(pid):
|
return pid, argv
|
||||||
return pid
|
|
||||||
pid = parent_of(pid)
|
pid = parent_of(pid)
|
||||||
|
return 0, []
|
||||||
|
|
||||||
|
|
||||||
|
def pid_start_time(pid):
|
||||||
|
"""Field 22 of /proc/<pid>/stat: when the process started, in clock ticks.
|
||||||
|
|
||||||
|
Pins a pid to one particular process. Pids are reused, and state files
|
||||||
|
outlive reboots -- without this, a file left by a crashed session whose pid
|
||||||
|
is later handed to something unrelated reads as a live session forever.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open("/proc/%d/stat" % pid) as fh:
|
||||||
|
data = fh.read()
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
# Field 2 is the command name, parenthesised, and may itself contain spaces
|
||||||
|
# and a ')'. Everything after the last ')' is field 3 onwards.
|
||||||
|
tail = data[data.rfind(")") + 2:].split()
|
||||||
|
try:
|
||||||
|
return int(tail[19])
|
||||||
|
except (IndexError, ValueError):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def alive(pid):
|
def alive(pid, start=0):
|
||||||
return pid > 0 and os.path.exists("/proc/%d" % pid)
|
"""Is that pid still the process it was? Inputs come from disk, so both
|
||||||
|
arguments are coerced rather than trusted."""
|
||||||
|
pid = int(number(pid))
|
||||||
|
if pid <= 0 or not os.path.exists("/proc/%d" % pid):
|
||||||
|
return False
|
||||||
|
# A file written before start times were recorded has nothing to compare.
|
||||||
|
start = number(start)
|
||||||
|
return not start or pid_start_time(pid) == start
|
||||||
|
|
||||||
|
|
||||||
def sweep_dead(keep):
|
def sweep_dead(keep):
|
||||||
@@ -190,12 +339,21 @@ def sweep_dead(keep):
|
|||||||
path = os.path.join(STATE_DIR, name)
|
path = os.path.join(STATE_DIR, name)
|
||||||
try:
|
try:
|
||||||
with open(path, "r") as fh:
|
with open(path, "r") as fh:
|
||||||
pid = json.load(fh).get("pid", 0)
|
stale = json.load(fh)
|
||||||
|
if not isinstance(stale, dict):
|
||||||
|
continue
|
||||||
|
pid = stale.get("pid", 0)
|
||||||
except (OSError, ValueError, AttributeError):
|
except (OSError, ValueError, AttributeError):
|
||||||
continue
|
continue
|
||||||
|
except Exception:
|
||||||
|
# One unreadable file must not abort the sweep, and above all must
|
||||||
|
# not abort the caller: this runs before the hook writes its own
|
||||||
|
# state, so an exception here would stop new sessions appearing at
|
||||||
|
# all, for as long as the bad file sits there.
|
||||||
|
continue
|
||||||
# pid 0 means the hook could not identify the process; there is nothing
|
# pid 0 means the hook could not identify the process; there is nothing
|
||||||
# to test for liveness, so leave it to the reader's age cutoff.
|
# to test for liveness, so leave it to the reader's age cutoff.
|
||||||
if pid and not alive(pid):
|
if pid and not alive(pid, stale.get("pid_start", 0)):
|
||||||
for victim in (path, path + ".lock"):
|
for victim in (path, path + ".lock"):
|
||||||
try:
|
try:
|
||||||
os.unlink(victim)
|
os.unlink(victim)
|
||||||
@@ -210,7 +368,23 @@ def write_atomic(path, payload):
|
|||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
def apply_event(event, state, path, now):
|
def open_lock(path):
|
||||||
|
"""Open the lock file without following a symlink.
|
||||||
|
|
||||||
|
A plain open() on a symlinked lock path truncates whatever it points at.
|
||||||
|
Nothing is escalated by that on a single-user machine, but a status
|
||||||
|
indicator has no business truncating files it was pointed at.
|
||||||
|
"""
|
||||||
|
flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW
|
||||||
|
try:
|
||||||
|
return os.fdopen(os.open(path, flags, 0o600), "r+")
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in (errno.ELOOP, errno.EMLINK):
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def apply_event(event, state, path, now, claude_pid):
|
||||||
"""Read the current state, decide, and write. Must run under the lock."""
|
"""Read the current state, decide, and write. Must run under the lock."""
|
||||||
if event.get("hook_event_name") == "SessionStart":
|
if event.get("hook_event_name") == "SessionStart":
|
||||||
# Swept before any early return: a resumed session keeps its id, so its
|
# Swept before any early return: a resumed session keeps its id, so its
|
||||||
@@ -219,9 +393,11 @@ def apply_event(event, state, path, now):
|
|||||||
sweep_dead(keep=os.path.basename(path))
|
sweep_dead(keep=os.path.basename(path))
|
||||||
|
|
||||||
if state == "end":
|
if state == "end":
|
||||||
for victim in (path, path + ".lock"):
|
# Only the state file. Unlinking the lock while holding it drops mutual
|
||||||
|
# exclusion -- a hook already blocked on the old inode and one that
|
||||||
|
# creates a new file are then both inside the critical section.
|
||||||
try:
|
try:
|
||||||
os.unlink(victim)
|
os.unlink(path)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
@@ -238,76 +414,105 @@ def apply_event(event, state, path, now):
|
|||||||
# Normalised here so the comparison below is against what would actually be
|
# Normalised here so the comparison below is against what would actually be
|
||||||
# stored: comparing a stored "" to a raw notification message rewrites the
|
# stored: comparing a stored "" to a raw notification message rewrites the
|
||||||
# file on every idle_prompt for no change at all.
|
# file on every idle_prompt for no change at all.
|
||||||
message = event.get("message", "") if state == "blocked" else ""
|
message = clip(event.get("message", "")) if state == "blocked" else ""
|
||||||
|
|
||||||
name = event.get("hook_event_name")
|
name = event.get("hook_event_name")
|
||||||
agents = int(previous.get("agents", 0)) if previous else 0
|
agents = int(number(previous.get("agents"))) if previous else 0
|
||||||
|
agents = max(0, min(agents, 999))
|
||||||
# Whether the main agent has finished its turn. Tracked separately from the
|
# Whether the main agent has finished its turn. Tracked separately from the
|
||||||
# state because with background subagents both are true at once: the turn is
|
# state because with background subagents both are true at once: the turn is
|
||||||
# over and work is still running.
|
# over and work is still running.
|
||||||
stopped = bool(previous.get("stopped")) if previous else False
|
stopped = bool(previous.get("stopped")) if previous else False
|
||||||
|
|
||||||
|
# An event that lost a race carries an older timestamp than what is already
|
||||||
|
# stored. Its *state* must not be applied -- that is last-writer-wins, and
|
||||||
|
# replaying an old one would resurrect a state the session has left.
|
||||||
|
previous_ts = number(previous.get("event_ts")) if previous else 0
|
||||||
|
if previous_ts > now + FUTURE_SLACK:
|
||||||
|
previous_ts = 0 # corrupt, and trusting it would freeze this session
|
||||||
|
stale = previous_ts > now
|
||||||
|
|
||||||
|
# Applied whether or not this event lost the race above: a snapshot says
|
||||||
|
# what was running when the event fired, and even a slightly stale one is a
|
||||||
|
# better answer than a number left over from an older event still. Nothing
|
||||||
|
# accumulates, so nothing leaks when a subagent dies without ever sending
|
||||||
|
# its SubagentStop -- the next event carrying a list corrects the count.
|
||||||
|
snapshot = running_agents(event)
|
||||||
|
if snapshot is not None:
|
||||||
|
agents = snapshot
|
||||||
|
|
||||||
|
if stale:
|
||||||
|
# Keep the stored state and flag; the snapshot above still stands.
|
||||||
|
state = previous.get("state", state)
|
||||||
|
else:
|
||||||
if name in ("SessionStart", "UserPromptSubmit"):
|
if name in ("SessionStart", "UserPromptSubmit"):
|
||||||
# A new turn from you starts a new batch. This also bounds the damage
|
stopped = False
|
||||||
# when a subagent dies without its SubagentStop ever arriving: the count
|
|
||||||
# cannot leak past the next thing you type.
|
|
||||||
agents, stopped = 0, False
|
|
||||||
elif name == "PreToolUse":
|
|
||||||
agents += 1
|
|
||||||
elif name == "SubagentStop":
|
|
||||||
agents = max(0, agents - 1)
|
|
||||||
elif name == "Stop":
|
elif name == "Stop":
|
||||||
stopped = True
|
stopped = True
|
||||||
elif name == "PostToolUse":
|
elif name == "PostToolUse":
|
||||||
stopped = False
|
stopped = False
|
||||||
|
|
||||||
if name == "SubagentStop":
|
if name == "SubagentStop":
|
||||||
# The last subagent finishing is what finally frees a session whose main
|
# The last subagent finishing is what finally frees a session whose
|
||||||
# agent stopped long ago.
|
# main agent stopped long ago.
|
||||||
state = "waiting" if (stopped and agents == 0) else "busy"
|
state = "waiting" if (stopped and agents == 0) else "busy"
|
||||||
elif state == "waiting" and agents > 0:
|
elif state == "waiting" and agents > 0:
|
||||||
# The turn ended but the batch is still running, and the session will
|
# The turn ended but the batch is still running, and the session
|
||||||
# pick the results up itself. Calling it "waiting" would send you to a
|
# will pick the results up itself. Calling it "waiting" would send
|
||||||
# terminal that does not need you.
|
# you to a terminal that does not need you.
|
||||||
state = "busy"
|
state = "busy"
|
||||||
|
|
||||||
|
# A pending question outlives everything except an answer to it.
|
||||||
|
if (previous and previous.get("state") == "blocked"
|
||||||
|
and state != "blocked" and name not in BLOCK_CLEARING):
|
||||||
|
state = "blocked"
|
||||||
|
message = previous.get("message", "")
|
||||||
|
|
||||||
|
# The pid is resolved by the caller and compared in the unchanged-check
|
||||||
|
# below, not merely stored. A session resumed under a new pid, or one whose
|
||||||
|
# pid was recorded wrongly, would otherwise keep the stale value for as
|
||||||
|
# long as its state happens not to change -- and the reader, finding that
|
||||||
|
# process gone, would drop a perfectly live session from the panel.
|
||||||
if previous:
|
if previous:
|
||||||
# Auto-compaction raises SessionStart again, in the middle of a turn the
|
# Auto-compaction raises SessionStart again, in the middle of a turn the
|
||||||
# session is still working on. Taking it at face value would flip a busy
|
# session is still working on. Taking it at face value would flip a busy
|
||||||
# session to idle until the next tool call corrected it.
|
# session to waiting until the next tool call corrected it.
|
||||||
if event.get("hook_event_name") == "SessionStart" and event.get("source") == "compact":
|
if event.get("hook_event_name") == "SessionStart" and event.get("source") == "compact":
|
||||||
return
|
return
|
||||||
# Refuse events that lost a race with a newer one.
|
|
||||||
if previous.get("event_ts", 0) > now:
|
|
||||||
return
|
|
||||||
# Nothing new to publish: stay quiet so the directory monitor stays quiet.
|
# Nothing new to publish: stay quiet so the directory monitor stays quiet.
|
||||||
if (previous.get("state") == state
|
if (previous.get("state") == state
|
||||||
and previous.get("message", "") == message
|
and previous.get("message", "") == message
|
||||||
and previous.get("agents", 0) == agents
|
and previous.get("agents", 0) == agents
|
||||||
and bool(previous.get("stopped")) == stopped):
|
and bool(previous.get("stopped")) == stopped
|
||||||
|
and previous.get("pid", 0) == claude_pid):
|
||||||
return
|
return
|
||||||
|
|
||||||
claude_pid = find_claude_pid()
|
|
||||||
env = read_environ(claude_pid) if claude_pid else {}
|
env = read_environ(claude_pid) if claude_pid else {}
|
||||||
|
|
||||||
write_atomic(path, {
|
write_atomic(path, {
|
||||||
"session_id": event.get("session_id"),
|
"session_id": event.get("session_id"),
|
||||||
"state": state,
|
"state": state,
|
||||||
"cwd": event.get("cwd") or "",
|
"cwd": clip(event.get("cwd") or ""),
|
||||||
# Age is measured from the moment the state was entered, not from the
|
# Age is measured from the moment the state was entered, not from the
|
||||||
# last event, so "waiting 40 min" survives unrelated later writes.
|
# last event, so "waiting 40 min" survives unrelated later writes.
|
||||||
"since": previous["since"] if previous and previous.get("state") == state else now,
|
"since": previous["since"] if previous and previous.get("state") == state else now,
|
||||||
"event_ts": now,
|
# When the session itself began, as opposed to when it entered this
|
||||||
|
# state. Seniority between chips is decided on this: a session that
|
||||||
|
# changed state a moment ago has not become the younger of the two.
|
||||||
|
"started": (previous.get("started") if previous else None) or now,
|
||||||
|
"event_ts": max(now, previous_ts),
|
||||||
# 0 means "could not tell"; the reader must not take that for "dead".
|
# 0 means "could not tell"; the reader must not take that for "dead".
|
||||||
"pid": claude_pid,
|
"pid": claude_pid,
|
||||||
|
"pid_start": pid_start_time(claude_pid) if claude_pid else 0,
|
||||||
"event": event.get("hook_event_name", ""),
|
"event": event.get("hook_event_name", ""),
|
||||||
"notification_type": event.get("notification_type", ""),
|
"notification_type": event.get("notification_type", ""),
|
||||||
"message": message,
|
"message": clip(message),
|
||||||
"agents": agents,
|
"agents": agents,
|
||||||
"stopped": stopped,
|
"stopped": stopped,
|
||||||
"zellij_session": env.get("ZELLIJ_SESSION_NAME", ""),
|
# Kept from the previous write when this event could not identify the
|
||||||
"zellij_pane": env.get("ZELLIJ_PANE_ID", ""),
|
# process: a momentary failure should not blank out where the session is.
|
||||||
"transcript": event.get("transcript_path", ""),
|
"zellij_session": clip(env.get("ZELLIJ_SESSION_NAME")
|
||||||
|
or (previous.get("zellij_session", "") if previous else "")),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -330,6 +535,13 @@ def main():
|
|||||||
if state is None:
|
if state is None:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# After the debug log, so that a session missing from the panel can still be
|
||||||
|
# explained by the log, and before the state file is touched at all: a
|
||||||
|
# headless run must not even delete on SessionEnd, since it never wrote.
|
||||||
|
claude_pid, claude_argv = find_claude()
|
||||||
|
if is_headless(claude_argv):
|
||||||
|
return 0
|
||||||
|
|
||||||
os.makedirs(STATE_DIR, exist_ok=True)
|
os.makedirs(STATE_DIR, exist_ok=True)
|
||||||
path = os.path.join(STATE_DIR, "%s.json" % session_id)
|
path = os.path.join(STATE_DIR, "%s.json" % session_id)
|
||||||
|
|
||||||
@@ -338,9 +550,12 @@ def main():
|
|||||||
# without a lock both processes read the same "previous" and the loser's
|
# without a lock both processes read the same "previous" and the loser's
|
||||||
# write still lands last, pinning a finished session at "busy". The lock is
|
# write still lands last, pinning a finished session at "busy". The lock is
|
||||||
# a separate file because write_atomic replaces the inode of the real one.
|
# a separate file because write_atomic replaces the inode of the real one.
|
||||||
with open(path + ".lock", "w") as lock:
|
lock = open_lock(path + ".lock")
|
||||||
|
if lock is None:
|
||||||
|
return 0
|
||||||
|
with lock:
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
apply_event(event, state, path, now)
|
apply_event(event, state, path, now, claude_pid)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+38
-13
@@ -11,7 +11,9 @@ Usage: install.py [--uninstall] [--settings PATH]
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-hook.py")
|
HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-hook.py")
|
||||||
@@ -21,17 +23,11 @@ HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-h
|
|||||||
# before it writes -- observed with `claude -p`, which left a session stuck at
|
# before it writes -- observed with `claude -p`, which left a session stuck at
|
||||||
# "busy" forever. They fire at most once per turn, so ~20 ms is free; the
|
# "busy" forever. They fire at most once per turn, so ~20 ms is free; the
|
||||||
# per-tool-call events stay async so they never sit in the agent's loop.
|
# per-tool-call events stay async so they never sit in the agent's loop.
|
||||||
# (async, matcher). PreToolUse is matched to the agent-spawning tools alone:
|
# (async, matcher).
|
||||||
# unmatched it would fire on every tool call in every session, and the only
|
|
||||||
# thing it is here for is to count subagents as they start. The pattern is
|
|
||||||
# anchored because it is a regex -- a bare "Task" also matches TaskCreate,
|
|
||||||
# TaskUpdate and friends, which are not subagents. The hook re-checks the name
|
|
||||||
# anyway, in case a future matcher works differently.
|
|
||||||
EVENTS = {
|
EVENTS = {
|
||||||
"SessionStart": (True, ""),
|
"SessionStart": (True, ""),
|
||||||
"UserPromptSubmit": (True, ""),
|
"UserPromptSubmit": (True, ""),
|
||||||
"Notification": (True, ""),
|
"Notification": (True, ""),
|
||||||
"PreToolUse": (True, "^(Agent|Task)$"),
|
|
||||||
"PostToolUse": (True, ""),
|
"PostToolUse": (True, ""),
|
||||||
"PreCompact": (True, ""),
|
"PreCompact": (True, ""),
|
||||||
"SubagentStop": (True, ""),
|
"SubagentStop": (True, ""),
|
||||||
@@ -39,10 +35,20 @@ EVENTS = {
|
|||||||
"SessionEnd": (False, ""),
|
"SessionEnd": (False, ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Registered once, no longer. PreToolUse existed to count subagents as they
|
||||||
|
# started; the count now comes from the snapshot the events carry themselves
|
||||||
|
# (see running_agents in the hook). Listed rather than forgotten because both
|
||||||
|
# install and uninstall sweep these out: an entry left behind would go on
|
||||||
|
# spawning the hook on every agent launch for nothing, and an uninstall that
|
||||||
|
# leaves our registrations in settings.json is not an uninstall.
|
||||||
|
LEGACY_EVENTS = ("PreToolUse",)
|
||||||
|
|
||||||
|
|
||||||
def entry(spec):
|
def entry(spec):
|
||||||
async_, matcher = spec
|
async_, matcher = spec
|
||||||
hook = {"type": "command", "command": HOOK, "timeout": 5}
|
# Claude Code runs the command through a shell, so a repository path
|
||||||
|
# containing a space -- or worse -- has to survive the trip.
|
||||||
|
hook = {"type": "command", "command": shlex.quote(HOOK), "timeout": 5}
|
||||||
if async_:
|
if async_:
|
||||||
hook["async"] = True
|
hook["async"] = True
|
||||||
return {"matcher": matcher, "hooks": [hook]}
|
return {"matcher": matcher, "hooks": [hook]}
|
||||||
@@ -50,7 +56,7 @@ def entry(spec):
|
|||||||
|
|
||||||
def is_ours(group):
|
def is_ours(group):
|
||||||
return any(
|
return any(
|
||||||
h.get("command", "").endswith("claude-status-hook.py")
|
h.get("command", "").rstrip("'\"").endswith("claude-status-hook.py")
|
||||||
for h in group.get("hooks", [])
|
for h in group.get("hooks", [])
|
||||||
if isinstance(h, dict)
|
if isinstance(h, dict)
|
||||||
)
|
)
|
||||||
@@ -60,7 +66,15 @@ def main():
|
|||||||
uninstall = "--uninstall" in sys.argv
|
uninstall = "--uninstall" in sys.argv
|
||||||
path = os.path.expanduser("~/.claude/settings.json")
|
path = os.path.expanduser("~/.claude/settings.json")
|
||||||
if "--settings" in sys.argv:
|
if "--settings" in sys.argv:
|
||||||
|
try:
|
||||||
path = sys.argv[sys.argv.index("--settings") + 1]
|
path = sys.argv[sys.argv.index("--settings") + 1]
|
||||||
|
except IndexError:
|
||||||
|
sys.exit("--settings needs a path")
|
||||||
|
# Written through, not over: a settings.json symlinked out of a dotfiles
|
||||||
|
# repository would otherwise be replaced by a regular file, silently
|
||||||
|
# detaching it from the repository that is supposed to manage it.
|
||||||
|
path = os.path.realpath(path)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path) as fh:
|
with open(path) as fh:
|
||||||
@@ -70,13 +84,17 @@ def main():
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
sys.exit("refusing to touch malformed %s: %s" % (path, exc))
|
sys.exit("refusing to touch malformed %s: %s" % (path, exc))
|
||||||
|
|
||||||
if os.path.exists(path):
|
# Kept from the first run only. Overwriting it on every run would, on the
|
||||||
|
# second run, replace the pristine copy with the already-modified one --
|
||||||
|
# which is exactly when someone reaches for a backup.
|
||||||
|
if os.path.exists(path) and not os.path.exists(path + ".bak"):
|
||||||
shutil.copyfile(path, path + ".bak")
|
shutil.copyfile(path, path + ".bak")
|
||||||
|
shutil.copymode(path, path + ".bak")
|
||||||
|
|
||||||
hooks = settings.setdefault("hooks", {})
|
hooks = settings.setdefault("hooks", {})
|
||||||
for event in EVENTS:
|
for event in list(EVENTS) + list(LEGACY_EVENTS):
|
||||||
groups = [g for g in hooks.get(event, []) if not is_ours(g)]
|
groups = [g for g in hooks.get(event, []) if not is_ours(g)]
|
||||||
if not uninstall:
|
if not uninstall and event in EVENTS:
|
||||||
groups.append(entry(EVENTS[event]))
|
groups.append(entry(EVENTS[event]))
|
||||||
if groups:
|
if groups:
|
||||||
hooks[event] = groups
|
hooks[event] = groups
|
||||||
@@ -92,10 +110,17 @@ def main():
|
|||||||
with open(tmp, "w") as fh:
|
with open(tmp, "w") as fh:
|
||||||
json.dump(settings, fh, indent=2)
|
json.dump(settings, fh, indent=2)
|
||||||
fh.write("\n")
|
fh.write("\n")
|
||||||
|
# A replace discards the original's mode. settings.json may hold API keys
|
||||||
|
# and may have been deliberately narrowed to 0600; silently widening it to
|
||||||
|
# the umask default would undo that without a word.
|
||||||
|
try:
|
||||||
|
os.chmod(tmp, stat.S_IMODE(os.stat(path).st_mode))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
|
|
||||||
print("%s %s in %s" % ("removed" if uninstall else "installed", HOOK, path))
|
print("%s %s in %s" % ("removed" if uninstall else "installed", HOOK, path))
|
||||||
print("restart running claude sessions for the change to take effect")
|
print("running sessions pick this up on their own; no restart needed")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+32
-13
@@ -1,4 +1,4 @@
|
|||||||
// Three-character chip labels for the panel.
|
// Short chip labels for the panel: three characters by default, settable up.
|
||||||
//
|
//
|
||||||
// A chip per session only pays off if the label stays put. Two rules do that:
|
// A chip per session only pays off if the label stays put. Two rules do that:
|
||||||
// labels are assigned oldest-session-first, so a session starting now takes the
|
// labels are assigned oldest-session-first, so a session starting now takes the
|
||||||
@@ -9,39 +9,58 @@
|
|||||||
//
|
//
|
||||||
// Imports nothing, so it runs under plain node or gjs.
|
// Imports nothing, so it runs under plain node or gjs.
|
||||||
|
|
||||||
const MAX = 3;
|
// Both entry points take a width, and both default to this: the module is
|
||||||
|
// imported by tests and by the indicator alike, and a caller that forgets the
|
||||||
|
// setting should get the documented default rather than a stray one.
|
||||||
|
const DEFAULT_WIDTH = 3;
|
||||||
|
|
||||||
|
/** Widths arrive from GSettings and from tests. One character is the floor:
|
||||||
|
* at zero every label would be empty and the collision loop would not end. */
|
||||||
|
function usable(width) {
|
||||||
|
const n = Math.trunc(Number(width));
|
||||||
|
return Number.isFinite(n) && n >= 1 ? n : DEFAULT_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
/** Split on separators and camelCase humps: "pet-project-server", "outlineMcp". */
|
/** Split on separators and camelCase humps: "pet-project-server", "outlineMcp". */
|
||||||
function segments(name) {
|
function segments(name) {
|
||||||
|
// Unicode-aware: splitting on [^a-zA-Z0-9] makes every Cyrillic letter a
|
||||||
|
// separator, so "проект" reduces to nothing and every non-Latin project
|
||||||
|
// ends up sharing the label "?".
|
||||||
return name
|
return name
|
||||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, '$1 $2')
|
||||||
.split(/[^a-zA-Z0-9]+/)
|
.split(/[^\p{L}\p{N}]+/u)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Up to three characters for a project name.
|
/** Up to `width` characters for a project name.
|
||||||
*
|
*
|
||||||
* Initials for multi-segment names, first letters for single words. Initials
|
* Initials for multi-segment names, first letters for single words. Initials
|
||||||
* matter more than they look: a plain prefix collapses "dev-skills" and
|
* matter more than they look: a plain prefix collapses "dev-skills" and
|
||||||
* "dev-conventions" onto the same "dev", which is the exact case this has to
|
* "dev-conventions" onto the same "dev", which is the exact case this has to
|
||||||
* keep apart.
|
* keep apart. A wider label takes more initials, not a longer prefix, for the
|
||||||
|
* same reason.
|
||||||
*/
|
*/
|
||||||
export function abbreviate(name) {
|
export function abbreviate(name, width = DEFAULT_WIDTH) {
|
||||||
const parts = segments(String(name ?? ''));
|
const parts = segments(String(name ?? ''));
|
||||||
if (!parts.length)
|
if (!parts.length)
|
||||||
return '?';
|
return '?';
|
||||||
const raw = parts.length > 1
|
const raw = parts.length > 1
|
||||||
? parts.map(p => p[0]).join('')
|
? parts.map(p => p[0]).join('')
|
||||||
: parts[0];
|
: parts[0];
|
||||||
return raw.slice(0, MAX).toLowerCase();
|
return raw.slice(0, usable(width)).toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assign a label to every session, reusing the ones already handed out.
|
/** Assign a label to every session, reusing the ones already handed out.
|
||||||
*
|
*
|
||||||
* `previous` is the mapping from the last run; pass the returned map back in.
|
* `previous` is the mapping from the last run; pass the returned map back in.
|
||||||
* Sessions absent from `sessions` drop out, which frees their label for reuse.
|
* Sessions absent from `sessions` drop out, which frees their label for reuse.
|
||||||
|
*
|
||||||
|
* `width` applies to labels handed out now. Kept labels are kept whatever
|
||||||
|
* width they were cut at -- stickiness outranks it, and the caller that
|
||||||
|
* changes the width is the one that has to drop the old map.
|
||||||
*/
|
*/
|
||||||
export function assignChips(sessions, previous = new Map()) {
|
export function assignChips(sessions, previous = new Map(), width = DEFAULT_WIDTH) {
|
||||||
|
const max = usable(width);
|
||||||
const labels = new Map();
|
const labels = new Map();
|
||||||
const taken = new Set();
|
const taken = new Set();
|
||||||
|
|
||||||
@@ -58,13 +77,13 @@ export function assignChips(sessions, previous = new Map()) {
|
|||||||
.sort((a, b) => (a.since || 0) - (b.since || 0));
|
.sort((a, b) => (a.since || 0) - (b.since || 0));
|
||||||
|
|
||||||
for (const session of fresh) {
|
for (const session of fresh) {
|
||||||
const base = abbreviate(session.base);
|
const base = abbreviate(session.base, max);
|
||||||
let label = base;
|
let label = base;
|
||||||
// Digits eat into the base rather than extending past three characters,
|
// Digits eat into the base rather than extending past the width, so
|
||||||
// so every chip stays the same width and the row does not ripple.
|
// every chip stays the same size and the row does not ripple.
|
||||||
for (let n = 2; taken.has(label); n++) {
|
for (let n = 2; taken.has(label); n++) {
|
||||||
const suffix = String(n);
|
const suffix = String(n);
|
||||||
label = base.slice(0, Math.max(1, MAX - suffix.length)) + suffix;
|
label = base.slice(0, Math.max(1, max - suffix.length)) + suffix;
|
||||||
}
|
}
|
||||||
labels.set(session.sessionId, label);
|
labels.set(session.sessionId, label);
|
||||||
taken.add(label);
|
taken.add(label);
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ export function formatAge(seconds) {
|
|||||||
if (m < 60)
|
if (m < 60)
|
||||||
return `${m}m`;
|
return `${m}m`;
|
||||||
const h = Math.floor(m / 60);
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24)
|
||||||
return `${h}h ${m % 60}m`;
|
return `${h}h ${m % 60}m`;
|
||||||
|
// Days, or a session left over the weekend reads as "120h 0m" and widens
|
||||||
|
// the very row the chip cap exists to keep narrow.
|
||||||
|
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Last path segment, with ~ collapsed. Two worktrees of one repo share a
|
/** Last path segment, with ~ collapsed. Two worktrees of one repo share a
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
// State glyphs, drawn with cairo alone.
|
// State glyphs, drawn with cairo alone.
|
||||||
//
|
//
|
||||||
// The panel is monochrome, so shape is the only channel left and these four
|
// The panel is monochrome, so shape is the only channel left and the three
|
||||||
// have to stay apart at 14 px. Kept free of St/Clutter/GNOME imports so the
|
// states have to stay apart at 14 px. Kept free of St/Clutter/GNOME imports so
|
||||||
// shapes can be rendered to a file and looked at, rather than guessed about.
|
// the shapes can be rendered to a file and looked at, rather than guessed at.
|
||||||
|
|
||||||
/** Draw `state` filling the given box, in the colour passed as {r,g,b,a} 0..1. */
|
/** Draw `state` filling the given box, in the colour passed as {r,g,b,a} 0..1. */
|
||||||
export function drawState(cr, state, width, height, color) {
|
export function drawState(cr, state, width, height, color) {
|
||||||
@@ -21,7 +21,7 @@ export function drawState(cr, state, width, height, color) {
|
|||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'blocked':
|
case 'blocked':
|
||||||
// Disc inside a ring: the most ink of the four, for the only state
|
// Disc inside a ring: the most ink of the three, for the only state
|
||||||
// where a session is stuck until you act. The inner disc has to be
|
// where a session is stuck until you act. The inner disc has to be
|
||||||
// big enough to register at 14 px, or this reads as plain "working".
|
// big enough to register at 14 px, or this reads as plain "working".
|
||||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||||
|
|||||||
+102
-72
@@ -6,7 +6,6 @@ import Clutter from 'gi://Clutter';
|
|||||||
import GLib from 'gi://GLib';
|
import GLib from 'gi://GLib';
|
||||||
import Pango from 'gi://Pango';
|
import Pango from 'gi://Pango';
|
||||||
|
|
||||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
|
||||||
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
|
||||||
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
|
||||||
import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js';
|
import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||||
@@ -27,11 +26,6 @@ function stateLabel(state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TERMINAL_CLASSES = [
|
|
||||||
'gnome-terminal', 'org.gnome.terminal', 'kitty', 'alacritty',
|
|
||||||
'foot', 'wezterm', 'konsole', 'xterm', 'ghostty',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Paint a state glyph in the panel's own text colour.
|
/** Paint a state glyph in the panel's own text colour.
|
||||||
*
|
*
|
||||||
* Nothing here picks a colour: the foreground comes from the theme node, so
|
* Nothing here picks a colour: the foreground comes from the theme node, so
|
||||||
@@ -44,8 +38,13 @@ function drawStateDot(area, state) {
|
|||||||
try {
|
try {
|
||||||
const [w, h] = area.get_surface_size();
|
const [w, h] = area.get_surface_size();
|
||||||
const c = area.get_theme_node().get_foreground_color();
|
const c = area.get_theme_node().get_foreground_color();
|
||||||
|
// Normalised by inspection rather than by assumption: the colour struct
|
||||||
|
// behind this changed between shell versions, and a wrong guess either
|
||||||
|
// way paints the glyph invisible or fully saturated.
|
||||||
|
const scale = Math.max(c.red, c.green, c.blue, c.alpha) > 1 ? 255 : 1;
|
||||||
drawState(cr, state, w, h, {
|
drawState(cr, state, w, h, {
|
||||||
r: c.red / 255, g: c.green / 255, b: c.blue / 255, a: c.alpha / 255,
|
r: c.red / scale, g: c.green / scale,
|
||||||
|
b: c.blue / scale, a: c.alpha / scale,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
cr.$dispose();
|
cr.$dispose();
|
||||||
@@ -62,6 +61,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
this._store = new SessionStore();
|
this._store = new SessionStore();
|
||||||
this._zellij = new ZellijTabs();
|
this._zellij = new ZellijTabs();
|
||||||
this._chipLabels = new Map();
|
this._chipLabels = new Map();
|
||||||
|
this._ageLabels = new Map();
|
||||||
this._rows = [];
|
this._rows = [];
|
||||||
|
|
||||||
this._buildPanel();
|
this._buildPanel();
|
||||||
@@ -69,6 +69,19 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
this._changedId = this._store.connect('changed', () => this._update());
|
this._changedId = this._store.connect('changed', () => this._update());
|
||||||
this._settingsChangedId = this._settings.connect('changed', () => this._update());
|
this._settingsChangedId = this._settings.connect('changed', () => this._update());
|
||||||
|
// Connected, not overridden: clutter_actor_destroy is not a vfunc, so a
|
||||||
|
// destroy() override only runs when JS calls it. An actor torn down any
|
||||||
|
// other way -- another extension rebuilding the panel boxes -- would
|
||||||
|
// leave the timer and the file monitor running against a disposed
|
||||||
|
// actor, screaming into the log every 20 seconds.
|
||||||
|
//
|
||||||
|
// Not named _onDestroy, which is the name PanelMenu.ButtonBox gives its
|
||||||
|
// own handler. It connects `this._onDestroy.bind(this)` in _init, and
|
||||||
|
// that resolves through the prototype chain -- so a subclass method of
|
||||||
|
// that name silently replaces it, and the St.Bin the panel box actually
|
||||||
|
// holds is never destroyed. Measured: an empty container stayed behind
|
||||||
|
// in the box on every teardown.
|
||||||
|
this.connect('destroy', () => this._teardown());
|
||||||
this._store.start();
|
this._store.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +89,8 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
_buildPanel() {
|
_buildPanel() {
|
||||||
// One chip per session rather than one aggregate: with five projects
|
// One chip per session rather than one aggregate: with five projects
|
||||||
// open, "the most urgent one" answers a question you did not ask. The
|
// open, "the most urgent one" answers a question you did not ask.
|
||||||
// row sits right of the clock, so it grows away from the centre.
|
// Where the row sits in the panel is a setting; extension.js places it.
|
||||||
this._chipBox = new St.BoxLayout({
|
this._chipBox = new St.BoxLayout({
|
||||||
style_class: 'panel-status-menu-box ccs-panel-box',
|
style_class: 'panel-status-menu-box ccs-panel-box',
|
||||||
y_align: Clutter.ActorAlign.CENTER,
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
@@ -101,18 +114,24 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
style_class: 'ccs-dot',
|
style_class: 'ccs-dot',
|
||||||
y_align: Clutter.ActorAlign.CENTER,
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
});
|
});
|
||||||
dot.set_width(14);
|
// Size comes from the stylesheet so St scales it: setting it here would
|
||||||
dot.set_height(14);
|
// pin the glyph to physical pixels and halve it on a HiDPI display.
|
||||||
dot.connect('repaint', area => drawStateDot(area, session.state));
|
dot.connect('repaint', area => drawStateDot(area, session.state));
|
||||||
chip.add_child(dot);
|
chip.add_child(dot);
|
||||||
|
|
||||||
let age = null;
|
let age = null;
|
||||||
if (this._settings.get_boolean('show-project-name')) {
|
if (this._settings.get_boolean('show-project-name')) {
|
||||||
chip.add_child(new St.Label({
|
const text = new St.Label({
|
||||||
style_class: 'ccs-chip-label',
|
style_class: 'ccs-chip-label',
|
||||||
y_align: Clutter.ActorAlign.CENTER,
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
text: label,
|
text: label,
|
||||||
}));
|
});
|
||||||
|
// With shortening off the label is a whole project name, which can
|
||||||
|
// be arbitrarily long: an unbounded label in the panel pushes the
|
||||||
|
// clock aside and, past a point, hands Pango a width it cannot
|
||||||
|
// represent.
|
||||||
|
text.clutter_text.ellipsize = Pango.EllipsizeMode.END;
|
||||||
|
chip.add_child(text);
|
||||||
}
|
}
|
||||||
if (withAge) {
|
if (withAge) {
|
||||||
age = new St.Label({
|
age = new St.Label({
|
||||||
@@ -164,14 +183,29 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
_updatePanel(sessions) {
|
_updatePanel(sessions) {
|
||||||
const showAge = this._settings.get_boolean('show-age');
|
const showAge = this._settings.get_boolean('show-age');
|
||||||
const abbreviate = this._settings.get_boolean('abbreviate-names');
|
const abbreviate = this._settings.get_boolean('abbreviate-names');
|
||||||
|
const maxChips = this._settings.get_int('max-chips');
|
||||||
|
const width = this._settings.get_int('abbrev-length');
|
||||||
|
|
||||||
|
// Labels are sticky by design, which here works against the setting:
|
||||||
|
// widening would leave every session on screen at its old width until
|
||||||
|
// it ended. Changing the width is the one thing that discards the map
|
||||||
|
// -- a relabelling the person asked for is not a label moving under
|
||||||
|
// their hand.
|
||||||
|
if (width !== this._chipWidth) {
|
||||||
|
this._chipLabels = new Map();
|
||||||
|
this._chipWidth = width;
|
||||||
|
}
|
||||||
|
|
||||||
this._chipLabels = assignChips(
|
this._chipLabels = assignChips(
|
||||||
sessions.map(s => ({
|
sessions.map(s => ({
|
||||||
sessionId: s.sessionId,
|
sessionId: s.sessionId,
|
||||||
since: s.since,
|
// Session age, not time in the current state: seniority decides
|
||||||
|
// who keeps the clean label, and a session that changed state a
|
||||||
|
// second ago has not thereby become the youngest.
|
||||||
|
since: s.started || s.since,
|
||||||
base: projectName(s.cwd),
|
base: projectName(s.cwd),
|
||||||
})),
|
})),
|
||||||
this._chipLabels);
|
this._chipLabels, width);
|
||||||
|
|
||||||
const labelFor = session => abbreviate
|
const labelFor = session => abbreviate
|
||||||
? this._chipLabels.get(session.sessionId)
|
? this._chipLabels.get(session.sessionId)
|
||||||
@@ -181,28 +215,55 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
// than sitting there empty.
|
// than sitting there empty.
|
||||||
this.visible = sessions.length > 0;
|
this.visible = sessions.length > 0;
|
||||||
|
|
||||||
// Age rides on the first chip only. Sessions are sorted by urgency, so
|
// Chips are ordered by urgency, so cutting the tail keeps the ones that
|
||||||
// that is the one whose age decides anything; five ages side by side
|
// need you soonest. Without a cap the row grows without bound, and it
|
||||||
// would just be a wide row of numbers.
|
// sits in the centre box -- enough sessions would shove the clock off
|
||||||
const ageOnFirst = showAge && sessions.length > 0;
|
// centre. Labels are still assigned over every session, so the menu and
|
||||||
const signature = sessions
|
// the panel agree and a chip does not change when the cap does.
|
||||||
|
const shown = sessions.slice(0, maxChips);
|
||||||
|
const hidden = sessions.length - shown.length;
|
||||||
|
|
||||||
|
// Age rides on the working chips, and on all of them. A session that
|
||||||
|
// is working is the one you might be waiting on, and how long it has
|
||||||
|
// been at it is the whole question -- five minutes is a build, forty
|
||||||
|
// is a session stuck on something. The states where nothing is
|
||||||
|
// happening have no such clock: their age says how long ago you
|
||||||
|
// stopped looking, which the row's own presence already says.
|
||||||
|
const ageOn = session => showAge && session.state === 'busy';
|
||||||
|
const signature = shown
|
||||||
.map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`)
|
.map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`)
|
||||||
.join('|') + `|${ageOnFirst}`;
|
.join('|') + `|${showAge}|${hidden}`;
|
||||||
if (signature !== this._chipSignature) {
|
if (signature !== this._chipSignature) {
|
||||||
this._chipBox.destroy_all_children();
|
this._chipBox.destroy_all_children();
|
||||||
this._ageLabel = null;
|
this._ageLabels = new Map();
|
||||||
sessions.forEach((session, i) => {
|
shown.forEach(session => {
|
||||||
const { chip, age } = this._buildChip(
|
const { chip, age } = this._buildChip(
|
||||||
session, labelFor(session), ageOnFirst && i === 0);
|
session, labelFor(session), ageOn(session));
|
||||||
if (age)
|
if (age)
|
||||||
this._ageLabel = { age, session };
|
this._ageLabels.set(session.sessionId, age);
|
||||||
this._chipBox.add_child(chip);
|
this._chipBox.add_child(chip);
|
||||||
});
|
});
|
||||||
|
if (hidden > 0) {
|
||||||
|
this._chipBox.add_child(new St.Label({
|
||||||
|
style_class: 'ccs-overflow',
|
||||||
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
|
text: `+${hidden}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
this._chipSignature = signature;
|
this._chipSignature = signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._ageLabel)
|
// Looked up again rather than captured: a session that returns to the
|
||||||
this._ageLabel.age.text = formatAge(this._ageOf(this._ageLabel.session));
|
// same state within one refresh keeps the signature unchanged, and a
|
||||||
|
// captured object would then show an age that stopped moving.
|
||||||
|
if (this._ageLabels.size) {
|
||||||
|
const byId = new Map(sessions.map(s => [s.sessionId, s]));
|
||||||
|
for (const [sessionId, label] of this._ageLabels) {
|
||||||
|
const current = byId.get(sessionId);
|
||||||
|
if (current)
|
||||||
|
label.text = formatAge(this._ageOf(current));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateMenu(sessions) {
|
_updateMenu(sessions) {
|
||||||
@@ -210,15 +271,18 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
// Rebuild only when the set of sessions or their states changed; ages
|
// Rebuild only when the set of sessions or their states changed; ages
|
||||||
// alone are refreshed in place so an open menu does not flicker.
|
// alone are refreshed in place so an open menu does not flicker.
|
||||||
|
// The tab name is not in the signature: it only feeds the subtitle,
|
||||||
|
// which is refreshed in place below.
|
||||||
const signature = sessions
|
const signature = sessions
|
||||||
.map(s => `${s.sessionId}:${s.state}:${this._tabFor(s) ?? ''}:${this._chipLabels?.get(s.sessionId) ?? ''}`)
|
.map(s => `${s.sessionId}:${s.state}:${this._chipLabels?.get(s.sessionId) ?? ''}`)
|
||||||
.join('|');
|
.join('|');
|
||||||
if (signature !== this._rowSignature) {
|
if (signature !== this._rowSignature) {
|
||||||
this._rebuildRows(sessions);
|
this._rebuildRows(sessions);
|
||||||
this._rowSignature = signature;
|
this._rowSignature = signature;
|
||||||
}
|
}
|
||||||
|
const byId = new Map(sessions.map(s => [s.sessionId, s]));
|
||||||
for (const row of this._rows) {
|
for (const row of this._rows) {
|
||||||
const session = sessions.find(s => s.sessionId === row.sessionId);
|
const session = byId.get(row.sessionId);
|
||||||
if (!session)
|
if (!session)
|
||||||
continue;
|
continue;
|
||||||
row.age.text = formatAge(this._ageOf(session));
|
row.age.text = formatAge(this._ageOf(session));
|
||||||
@@ -259,13 +323,12 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_buildRow(session) {
|
_buildRow(session) {
|
||||||
const tab = this._tabFor(session);
|
// The menu reports; it does not act. Rows are built inert rather than
|
||||||
// Reactivity is decided at construction, not patched afterwards:
|
// switched off afterwards, because PopupBaseMenuItem latches
|
||||||
// PopupBaseMenuItem latches _activatable in its constructor, so a row
|
// _activatable in its constructor and a row demoted later keeps the
|
||||||
// switched to reactive=false later keeps the styling of a clickable one
|
// styling of a clickable one.
|
||||||
// and still looks like it does something.
|
|
||||||
const item = new PopupMenu.PopupBaseMenuItem(
|
const item = new PopupMenu.PopupBaseMenuItem(
|
||||||
tab ? {} : { reactive: false, can_focus: false });
|
{ reactive: false, can_focus: false });
|
||||||
item.add_style_class_name('ccs-row');
|
item.add_style_class_name('ccs-row');
|
||||||
|
|
||||||
const column = new St.BoxLayout({ vertical: true, x_expand: true });
|
const column = new St.BoxLayout({ vertical: true, x_expand: true });
|
||||||
@@ -277,6 +340,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
style_class: `ccs-row-title ccs-${session.state}`,
|
style_class: `ccs-row-title ccs-${session.state}`,
|
||||||
x_expand: true,
|
x_expand: true,
|
||||||
});
|
});
|
||||||
|
title.clutter_text.ellipsize = Pango.EllipsizeMode.END;
|
||||||
const age = new St.Label({
|
const age = new St.Label({
|
||||||
text: formatAge(this._ageOf(session)),
|
text: formatAge(this._ageOf(session)),
|
||||||
style_class: 'ccs-row-age',
|
style_class: 'ccs-row-age',
|
||||||
@@ -296,9 +360,6 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
column.add_child(subtitle);
|
column.add_child(subtitle);
|
||||||
item.add_child(column);
|
item.add_child(column);
|
||||||
|
|
||||||
if (tab)
|
|
||||||
item.connect('activate', () => this._switchTo(session, tab));
|
|
||||||
|
|
||||||
this._rows.push({ sessionId: session.sessionId, age, subtitle });
|
this._rows.push({ sessionId: session.sessionId, age, subtitle });
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -360,41 +421,11 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
.catch(e => logError(e, 'claude-code-status: zellij refresh failed'));
|
.catch(e => logError(e, 'claude-code-status: zellij refresh failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
_switchTo(session, tab) {
|
|
||||||
this._zellij.goToTab(session.zellijSession, tab);
|
|
||||||
this._focusTerminal(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Best effort: raise a terminal window showing this zellij session.
|
|
||||||
*
|
|
||||||
* Matching by pid does not work for gnome-terminal, where every window
|
|
||||||
* belongs to one shared server process, so the window title is the only
|
|
||||||
* handle available -- and zellij puts the session name there.
|
|
||||||
*/
|
|
||||||
_focusTerminal(session) {
|
|
||||||
if (!session.zellijSession)
|
|
||||||
return;
|
|
||||||
// list_all_windows() rather than get_window_actors(): the latter is
|
|
||||||
// deprecated from GNOME 46 on, and this has to work across 45-48.
|
|
||||||
for (const win of global.display.list_all_windows()) {
|
|
||||||
const wmClass = (win.get_wm_class() ?? '').toLowerCase();
|
|
||||||
if (!TERMINAL_CLASSES.some(c => wmClass.includes(c)))
|
|
||||||
continue;
|
|
||||||
// Only a window that names this zellij session is raised. Falling
|
|
||||||
// back to any terminal at all would raise an unrelated one, which
|
|
||||||
// is worse than leaving focus where the user put it.
|
|
||||||
if ((win.get_title() ?? '').includes(session.zellijSession)) {
|
|
||||||
Main.activateWindow(win);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Visuals --------------------------------------------------------
|
|
||||||
|
|
||||||
// ---- Teardown -------------------------------------------------------
|
// ---- Teardown -------------------------------------------------------
|
||||||
|
|
||||||
destroy() {
|
_teardown() {
|
||||||
|
if (this._destroyed)
|
||||||
|
return;
|
||||||
this._destroyed = true;
|
this._destroyed = true;
|
||||||
this._zellij.destroy();
|
this._zellij.destroy();
|
||||||
if (this._changedId) {
|
if (this._changedId) {
|
||||||
@@ -406,6 +437,5 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
this._settingsChangedId = 0;
|
this._settingsChangedId = 0;
|
||||||
}
|
}
|
||||||
this._store.destroy();
|
this._store.destroy();
|
||||||
super.destroy();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+93
-12
@@ -27,6 +27,23 @@ const LIVENESS_INTERVAL = 20; // seconds
|
|||||||
// morning, which is exactly the case this indicator exists for.
|
// morning, which is exactly the case this indicator exists for.
|
||||||
const UNKNOWN_PID_MAX_AGE = 36 * 3600; // seconds
|
const UNKNOWN_PID_MAX_AGE = 36 * 3600; // seconds
|
||||||
|
|
||||||
|
// A hook killed between writing its temporary file and renaming it leaves the
|
||||||
|
// temporary behind. Old ones are swept; recent ones are left alone, because a
|
||||||
|
// hook may be part-way through writing one right now and deleting it would
|
||||||
|
// lose that update.
|
||||||
|
const TMP_MAX_AGE = 300; // seconds
|
||||||
|
|
||||||
|
// A state file is a few hundred bytes. Anything larger is corrupt or hostile,
|
||||||
|
// and reading it whole would happen inside the compositor: a symlink to
|
||||||
|
// /dev/zero took a test process past 4 GB in three seconds, which in
|
||||||
|
// gnome-shell is the session ending. The size is checked before the read.
|
||||||
|
const MAX_STATE_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
// Work here is on the compositor's main loop, and every session costs a menu
|
||||||
|
// row of five actors. Well past any real use, and cheap insurance against a
|
||||||
|
// directory someone filled up.
|
||||||
|
const MAX_SESSIONS = 64;
|
||||||
|
|
||||||
export function stateRank(state) {
|
export function stateRank(state) {
|
||||||
const i = STATES.indexOf(state);
|
const i = STATES.indexOf(state);
|
||||||
return i < 0 ? STATES.length : i;
|
return i < 0 ? STATES.length : i;
|
||||||
@@ -128,16 +145,20 @@ export const SessionStore = GObject.registerClass({
|
|||||||
let enumerator;
|
let enumerator;
|
||||||
try {
|
try {
|
||||||
enumerator = await this._dir.enumerate_children_async(
|
enumerator = await this._dir.enumerate_children_async(
|
||||||
'standard::name', Gio.FileQueryInfoFlags.NONE,
|
'standard::name,standard::size,time::modified', Gio.FileQueryInfoFlags.NONE,
|
||||||
GLib.PRIORITY_DEFAULT, cancellable);
|
GLib.PRIORITY_DEFAULT, cancellable);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// No directory yet means no sessions have ever run; not an error.
|
// No directory yet means no sessions have ever run; not an error.
|
||||||
if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
|
// NOT_DIRECTORY means something took the path -- also not worth a
|
||||||
|
// stack trace every 20 seconds for as long as it stays that way.
|
||||||
|
if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND) ||
|
||||||
|
e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_DIRECTORY))
|
||||||
return [];
|
return [];
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = [];
|
const names = [];
|
||||||
|
const locks = [];
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const batch = await enumerator.next_files_async(
|
const batch = await enumerator.next_files_async(
|
||||||
32, GLib.PRIORITY_DEFAULT, cancellable);
|
32, GLib.PRIORITY_DEFAULT, cancellable);
|
||||||
@@ -145,15 +166,31 @@ export const SessionStore = GObject.registerClass({
|
|||||||
break;
|
break;
|
||||||
for (const info of batch) {
|
for (const info of batch) {
|
||||||
const name = info.get_name();
|
const name = info.get_name();
|
||||||
// ".tmp" files are half-written state; "debug" is the hook's
|
// Only ".json" is state. ".lock" belongs to the hook, "debug"
|
||||||
// opt-in event log and is not a session.
|
// is its opt-in event log, and ".tmp" is an interrupted write.
|
||||||
if (name.endsWith('.json'))
|
if (name.endsWith('.json')) {
|
||||||
|
if (info.get_size() > MAX_STATE_BYTES) {
|
||||||
|
// Not read at all: the point is to never allocate it.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
names.push(name);
|
names.push(name);
|
||||||
|
} else if (name.endsWith('.tmp')) {
|
||||||
|
this._sweepStale(info, name);
|
||||||
|
} else if (name.endsWith('.json.lock')) {
|
||||||
|
locks.push({ info, name });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A lock whose state file is gone belongs to nothing; the hook only
|
||||||
|
// removes the pair together, so nobody else would ever clear it.
|
||||||
|
for (const { info, name } of locks) {
|
||||||
|
if (!names.includes(name.slice(0, -'.lock'.length)))
|
||||||
|
this._sweepStale(info, name);
|
||||||
|
}
|
||||||
|
|
||||||
const sessions = [];
|
const sessions = [];
|
||||||
for (const name of names) {
|
for (const name of names.slice(0, MAX_SESSIONS)) {
|
||||||
const session = await this._readOne(name, cancellable);
|
const session = await this._readOne(name, cancellable);
|
||||||
if (session)
|
if (session)
|
||||||
sessions.push(session);
|
sessions.push(session);
|
||||||
@@ -161,6 +198,24 @@ export const SessionStore = GObject.registerClass({
|
|||||||
return sessions;
|
return sessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete an abandoned file, once it is old enough to be sure nobody is
|
||||||
|
* part-way through writing it. */
|
||||||
|
_sweepStale(info, name) {
|
||||||
|
const modified = info.get_modification_date_time?.();
|
||||||
|
if (!modified)
|
||||||
|
return;
|
||||||
|
const age = GLib.DateTime.new_now_local().difference(modified) / 1e6;
|
||||||
|
if (age < TMP_MAX_AGE)
|
||||||
|
return;
|
||||||
|
this._dir.get_child(name).delete_async(GLib.PRIORITY_LOW, null, (obj, res) => {
|
||||||
|
try {
|
||||||
|
obj.delete_finish(res);
|
||||||
|
} catch (e) {
|
||||||
|
// Gone already, or not ours to remove.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async _readOne(name, cancellable) {
|
async _readOne(name, cancellable) {
|
||||||
const file = this._dir.get_child(name);
|
const file = this._dir.get_child(name);
|
||||||
let raw;
|
let raw;
|
||||||
@@ -176,12 +231,13 @@ export const SessionStore = GObject.registerClass({
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
const pid = Number(raw.pid) || 0;
|
const pid = Number(raw.pid) || 0;
|
||||||
|
const pidStart = Number(raw.pid_start) || 0;
|
||||||
const eventTs = Number(raw.event_ts) || 0;
|
const eventTs = Number(raw.event_ts) || 0;
|
||||||
const age = GLib.get_real_time() / 1e6 - eventTs;
|
const age = GLib.get_real_time() / 1e6 - eventTs;
|
||||||
// pid 0 is "the hook could not tell", not "dead": treating it as dead
|
// pid 0 is "the hook could not tell", not "dead": treating it as dead
|
||||||
// would hide a perfectly live session, so those fall back to an age
|
// would hide a perfectly live session, so those fall back to an age
|
||||||
// cutoff instead.
|
// cutoff instead.
|
||||||
const gone = pid > 0 ? !isAlive(pid) : age > UNKNOWN_PID_MAX_AGE;
|
const gone = pid > 0 ? !isAlive(pid, pidStart) : age > UNKNOWN_PID_MAX_AGE;
|
||||||
if (gone) {
|
if (gone) {
|
||||||
// The terminal was killed without a SessionEnd hook. Removing the
|
// The terminal was killed without a SessionEnd hook. Removing the
|
||||||
// file here (rather than only hiding it) keeps the directory from
|
// file here (rather than only hiding it) keeps the directory from
|
||||||
@@ -209,12 +265,10 @@ export const SessionStore = GObject.registerClass({
|
|||||||
state,
|
state,
|
||||||
cwd: String(raw.cwd ?? ''),
|
cwd: String(raw.cwd ?? ''),
|
||||||
since: Number(raw.since) || 0,
|
since: Number(raw.since) || 0,
|
||||||
|
started: Number(raw.started) || 0,
|
||||||
pid,
|
pid,
|
||||||
message: String(raw.message ?? ''),
|
|
||||||
agents: Math.max(0, Number(raw.agents) || 0),
|
agents: Math.max(0, Number(raw.agents) || 0),
|
||||||
notificationType: String(raw.notification_type ?? ''),
|
|
||||||
zellijSession: String(raw.zellij_session ?? ''),
|
zellijSession: String(raw.zellij_session ?? ''),
|
||||||
zellijPane: String(raw.zellij_pane ?? ''),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +288,33 @@ export const SessionStore = GObject.registerClass({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function isAlive(pid) {
|
/** Is this pid still the process the hook recorded?
|
||||||
return pid > 0 && GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS);
|
*
|
||||||
|
* Existence alone is not enough. State files outlive reboots, and a pid from a
|
||||||
|
* previous boot is very likely to belong to something else now -- a session
|
||||||
|
* that died in a crash would otherwise sit in the panel forever, waiting for
|
||||||
|
* an answer nobody can give. The start time pins the pid to one process.
|
||||||
|
*/
|
||||||
|
function isAlive(pid, startTime) {
|
||||||
|
if (pid <= 0 || !GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS))
|
||||||
|
return false;
|
||||||
|
// Files written before start times were recorded have nothing to compare.
|
||||||
|
if (!startTime)
|
||||||
|
return true;
|
||||||
|
return readStartTime(pid) === startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStartTime(pid) {
|
||||||
|
try {
|
||||||
|
const [ok, bytes] = GLib.file_get_contents(`/proc/${pid}/stat`);
|
||||||
|
if (!ok)
|
||||||
|
return 0;
|
||||||
|
const data = new TextDecoder().decode(bytes);
|
||||||
|
// The command name is parenthesised and may contain spaces and ')',
|
||||||
|
// so fields are counted from after the last one.
|
||||||
|
const tail = data.slice(data.lastIndexOf(')') + 2).split(' ');
|
||||||
|
return Number(tail[19]) || 0;
|
||||||
|
} catch (e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-14
@@ -1,8 +1,8 @@
|
|||||||
// Maps a session's working directory to the zellij tab it is running in.
|
// Maps a session's working directory to the zellij tab it is running in.
|
||||||
//
|
//
|
||||||
// Knowing a session waits for you is only half the answer; the other half is
|
// Knowing a session waits for you is only half the answer; the other half is
|
||||||
// where to look. When sessions live in zellij tabs, the tab name is a better
|
// where to look. When sessions live in zellij tabs, the tab name answers that
|
||||||
// answer than a path, and zellij can be told to switch to it.
|
// better than a path does.
|
||||||
//
|
//
|
||||||
// `zellij action dump-layout` prints tab names with each pane's cwd but no pane
|
// `zellij action dump-layout` prints tab names with each pane's cwd but no pane
|
||||||
// ids, so ZELLIJ_PANE_ID from the hook cannot be used for the lookup and the
|
// ids, so ZELLIJ_PANE_ID from the hook cannot be used for the lookup and the
|
||||||
@@ -20,12 +20,18 @@ Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async');
|
|||||||
// does not spawn a zellij process every time it runs.
|
// does not spawn a zellij process every time it runs.
|
||||||
const CACHE_TTL = 120; // seconds
|
const CACHE_TTL = 120; // seconds
|
||||||
|
|
||||||
|
// One subprocess per distinct zellij session named in the state directory.
|
||||||
|
// In real use that is one or two; the bound is there because the names come
|
||||||
|
// from files, and a directory full of them would fork a process per name.
|
||||||
|
const MAX_SESSIONS = 8;
|
||||||
|
|
||||||
export class ZellijTabs {
|
export class ZellijTabs {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._cache = new Map(); // zellij session -> { at, tabs: [{name, cwds}] }
|
this._cache = new Map(); // zellij session -> { at, tabs: [{name, cwds}] }
|
||||||
this._inFlight = new Map();
|
this._inFlight = new Map();
|
||||||
this._available = null;
|
this._available = null;
|
||||||
this._cancellable = new Gio.Cancellable();
|
this._cancellable = new Gio.Cancellable();
|
||||||
|
this._children = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tab name for a working directory, or null when unknown. */
|
/** Tab name for a working directory, or null when unknown. */
|
||||||
@@ -58,7 +64,7 @@ export class ZellijTabs {
|
|||||||
return;
|
return;
|
||||||
const now = GLib.get_monotonic_time() / 1e6;
|
const now = GLib.get_monotonic_time() / 1e6;
|
||||||
const work = [];
|
const work = [];
|
||||||
for (const name of new Set(zellijSessions)) {
|
for (const name of [...new Set(zellijSessions)].slice(0, MAX_SESSIONS)) {
|
||||||
if (!name)
|
if (!name)
|
||||||
continue;
|
continue;
|
||||||
const entry = this._cache.get(name);
|
const entry = this._cache.get(name);
|
||||||
@@ -97,10 +103,15 @@ export class ZellijTabs {
|
|||||||
const proc = Gio.Subprocess.new(
|
const proc = Gio.Subprocess.new(
|
||||||
['zellij', '--session', session, 'action', 'dump-layout'],
|
['zellij', '--session', session, 'action', 'dump-layout'],
|
||||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE);
|
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE);
|
||||||
|
this._children.add(proc);
|
||||||
|
try {
|
||||||
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
|
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
|
||||||
if (!proc.get_successful())
|
if (!proc.get_successful())
|
||||||
return null;
|
return null;
|
||||||
return stdout ?? '';
|
return stdout ?? '';
|
||||||
|
} finally {
|
||||||
|
this._children.delete(proc);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// zellij not installed, or not on the shell's PATH: stop trying.
|
// zellij not installed, or not on the shell's PATH: stop trying.
|
||||||
if (e.matches?.(GLib.SpawnError, GLib.SpawnError.NOENT))
|
if (e.matches?.(GLib.SpawnError, GLib.SpawnError.NOENT))
|
||||||
@@ -112,20 +123,14 @@ export class ZellijTabs {
|
|||||||
/** Abandon any layout dump still running; the extension is going away. */
|
/** Abandon any layout dump still running; the extension is going away. */
|
||||||
destroy() {
|
destroy() {
|
||||||
this._cancellable.cancel();
|
this._cancellable.cancel();
|
||||||
|
// Cancelling only abandons the read; the child keeps running. A wedged
|
||||||
|
// zellij server would otherwise outlive the extension being disabled.
|
||||||
|
for (const proc of this._children)
|
||||||
|
proc.force_exit();
|
||||||
|
this._children.clear();
|
||||||
this._cache.clear();
|
this._cache.clear();
|
||||||
this._inFlight.clear();
|
this._inFlight.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Switch the given zellij session to a tab. Fire and forget. */
|
|
||||||
goToTab(session, tab) {
|
|
||||||
try {
|
|
||||||
Gio.Subprocess.new(
|
|
||||||
['zellij', '--session', session, 'action', 'go-to-tab-name', tab],
|
|
||||||
Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE);
|
|
||||||
} catch (e) {
|
|
||||||
logError(e, 'claude-code-status: zellij go-to-tab-name failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract tab names and their pane working directories from a KDL layout.
|
/** Extract tab names and their pane working directories from a KDL layout.
|
||||||
@@ -146,11 +151,23 @@ export function parseLayout(text) {
|
|||||||
tabs.push(current);
|
tabs.push(current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// A dump ends with new_tab_template and swap_tiled_layout blocks, whose
|
||||||
|
// own `tab` lines carry no name. Their panes belong to no tab at all;
|
||||||
|
// left attached to whatever came before, they make the last tab in the
|
||||||
|
// dump answer for every unmatched directory -- confidently and wrongly.
|
||||||
|
if (/^\s*(tab\s|tab\s*\{|new_tab_template|swap_tiled_layout|swap_floating_layout)/.test(line)) {
|
||||||
|
current = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!current)
|
if (!current)
|
||||||
continue;
|
continue;
|
||||||
const paneMatch = line.match(/^\s*pane\s.*?\bcwd="([^"]*)"/);
|
const paneMatch = line.match(/^\s*pane\s.*?\bcwd="([^"]*)"/);
|
||||||
if (paneMatch) {
|
if (paneMatch) {
|
||||||
const cwd = paneMatch[1];
|
const cwd = paneMatch[1];
|
||||||
|
// A relative pane cwd is meaningless without the layout-level one;
|
||||||
|
// joining against "" yields a relative path that matches nothing.
|
||||||
|
if (!cwd.startsWith('/') && !base)
|
||||||
|
continue;
|
||||||
const absolute = cwd.startsWith('/')
|
const absolute = cwd.startsWith('/')
|
||||||
? cwd
|
? cwd
|
||||||
: GLib.build_filenamev([base, cwd]);
|
: GLib.build_filenamev([base, cwd]);
|
||||||
|
|||||||
@@ -15,18 +15,45 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
|
|||||||
});
|
});
|
||||||
window.add(page);
|
window.add(page);
|
||||||
|
|
||||||
const dispGroup = new Adw.PreferencesGroup({ title: _('Panel') });
|
const placeGroup = new Adw.PreferencesGroup({
|
||||||
|
title: _('Placement'),
|
||||||
|
description: _('Where the row of chips sits in the top bar. Applied at once — no need to reload.'),
|
||||||
|
});
|
||||||
|
page.add(placeGroup);
|
||||||
|
|
||||||
|
placeGroup.add(this._comboRow(settings, 'panel-box',
|
||||||
|
_('Panel box'),
|
||||||
|
_('The centre box holds the clock; the right one is the system status area.'),
|
||||||
|
[
|
||||||
|
{ value: 'left', label: _('Left') },
|
||||||
|
{ value: 'center', label: _('Centre') },
|
||||||
|
{ value: 'right', label: _('Right') },
|
||||||
|
]));
|
||||||
|
placeGroup.add(this._spinRow(settings, 'panel-position',
|
||||||
|
_('Position in that box'),
|
||||||
|
_('0 is first. In the centre box, 1 puts the chips just right of the clock. Past the end means last.'),
|
||||||
|
0, 10));
|
||||||
|
|
||||||
|
const dispGroup = new Adw.PreferencesGroup({ title: _('Chips') });
|
||||||
page.add(dispGroup);
|
page.add(dispGroup);
|
||||||
|
|
||||||
dispGroup.add(this._switchRow(settings, 'show-project-name',
|
dispGroup.add(this._switchRow(settings, 'show-project-name',
|
||||||
_('Label chips with the project'),
|
_('Label chips with the project'),
|
||||||
_('Name the sessions, not just their states.')));
|
_('Name the sessions, not just their states.')));
|
||||||
dispGroup.add(this._switchRow(settings, 'abbreviate-names',
|
dispGroup.add(this._switchRow(settings, 'abbreviate-names',
|
||||||
_('Shorten names to three characters'),
|
_('Shorten names'),
|
||||||
_('“dev-skills” becomes “ds”. Collisions get a digit by seniority, so a label already on screen never changes.')));
|
_('“dev-skills” becomes “ds”. Collisions get a digit by seniority, so a label already on screen never changes.')));
|
||||||
|
dispGroup.add(this._spinRow(settings, 'abbrev-length',
|
||||||
|
_('Label length'),
|
||||||
|
_('Characters a shortened label may use. Three keeps the row narrow; longer reads more like the name. Changing it relabels every session at once.'),
|
||||||
|
3, 10));
|
||||||
|
dispGroup.add(this._spinRow(settings, 'max-chips',
|
||||||
|
_('Chips shown'),
|
||||||
|
_('The most urgent sessions get a chip; the rest are counted as “+N”.'),
|
||||||
|
1, 12));
|
||||||
dispGroup.add(this._switchRow(settings, 'show-age',
|
dispGroup.add(this._switchRow(settings, 'show-age',
|
||||||
_('Show time in state'),
|
_('Show working time'),
|
||||||
_('Shown on the most urgent session only.')));
|
_('A counter on every session that is working, so you can see how long it has been at it. The menu lists ages either way.')));
|
||||||
|
|
||||||
const zellijGroup = new Adw.PreferencesGroup({
|
const zellijGroup = new Adw.PreferencesGroup({
|
||||||
title: _('zellij'),
|
title: _('zellij'),
|
||||||
@@ -36,7 +63,7 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
|
|||||||
|
|
||||||
zellijGroup.add(this._switchRow(settings, 'zellij-integration',
|
zellijGroup.add(this._switchRow(settings, 'zellij-integration',
|
||||||
_('Resolve tab names'),
|
_('Resolve tab names'),
|
||||||
_('Show the zellij tab in the menu and switch to it on click. Ignored when zellij is not installed.')));
|
_('Name the zellij tab each session runs in. Ignored when zellij is not installed.')));
|
||||||
|
|
||||||
// --- Hooks ---------------------------------------------------------
|
// --- Hooks ---------------------------------------------------------
|
||||||
// The indicator is only as good as the hooks feeding it, and a silent
|
// The indicator is only as good as the hooks feeding it, and a silent
|
||||||
@@ -81,6 +108,11 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
|
|||||||
|
|
||||||
_hooksStatus() {
|
_hooksStatus() {
|
||||||
const path = GLib.build_filenamev([GLib.get_home_dir(), '.claude', 'settings.json']);
|
const path = GLib.build_filenamev([GLib.get_home_dir(), '.claude', 'settings.json']);
|
||||||
|
// Checked before reading: file_get_contents throws on a missing file,
|
||||||
|
// so without this the person who has installed nothing -- the one who
|
||||||
|
// most needs the instructions -- is told the file cannot be parsed.
|
||||||
|
if (!GLib.file_test(path, GLib.FileTest.EXISTS))
|
||||||
|
return _('No ~/.claude/settings.json yet — run the install command below');
|
||||||
try {
|
try {
|
||||||
const [ok, bytes] = GLib.file_get_contents(path);
|
const [ok, bytes] = GLib.file_get_contents(path);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
@@ -91,13 +123,50 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
|
|||||||
(g.hooks ?? []).some(h => (h.command ?? '').includes('claude-status-hook.py'))))
|
(g.hooks ?? []).some(h => (h.command ?? '').includes('claude-status-hook.py'))))
|
||||||
.map(([event]) => event);
|
.map(([event]) => event);
|
||||||
if (!events.length)
|
if (!events.length)
|
||||||
return _('Not installed — run the install command below, then restart your sessions');
|
return _('Not installed — run the install command below');
|
||||||
return `${_('Installed for')}: ${events.join(', ')}`;
|
return `${_('Installed for')}: ${events.join(', ')}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return _('~/.claude/settings.json could not be parsed');
|
return _('~/.claude/settings.json could not be parsed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_spinRow(settings, key, title, subtitle, lower, upper) {
|
||||||
|
const row = new Adw.SpinRow({
|
||||||
|
title, subtitle,
|
||||||
|
adjustment: new Gtk.Adjustment({
|
||||||
|
lower, upper, step_increment: 1, page_increment: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
settings.bind(key, row, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A string key with a fixed set of values.
|
||||||
|
*
|
||||||
|
* Bound by hand: Gio.Settings.bind maps a boolean to 'active' and an int
|
||||||
|
* to 'value', but a string to a selected index needs bind_with_mapping,
|
||||||
|
* which is not introspectable. The write direction is the only one wired
|
||||||
|
* up -- while this window is open, this row is the only thing that writes
|
||||||
|
* the key, and it is read afresh every time the window is built.
|
||||||
|
*/
|
||||||
|
_comboRow(settings, key, title, subtitle, options) {
|
||||||
|
const row = new Adw.ComboRow({
|
||||||
|
title, subtitle,
|
||||||
|
model: Gtk.StringList.new(options.map(o => o.label)),
|
||||||
|
});
|
||||||
|
const values = options.map(o => o.value);
|
||||||
|
const current = values.indexOf(settings.get_string(key));
|
||||||
|
// Set before connecting, so restoring the stored value is not itself
|
||||||
|
// taken for a change the person made.
|
||||||
|
row.selected = current < 0 ? 0 : current;
|
||||||
|
row.connect('notify::selected', () => {
|
||||||
|
const value = values[row.selected];
|
||||||
|
if (value)
|
||||||
|
settings.set_string(key, value);
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
_switchRow(settings, key, title, subtitle) {
|
_switchRow(settings, key, title, subtitle) {
|
||||||
const row = new Adw.SwitchRow({ title, subtitle });
|
const row = new Adw.SwitchRow({ title, subtitle });
|
||||||
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
|
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
<schemalist>
|
<schemalist>
|
||||||
<schema id="org.gnome.shell.extensions.claude-code-status"
|
<schema id="org.gnome.shell.extensions.claude-code-status"
|
||||||
path="/org/gnome/shell/extensions/claude-code-status/">
|
path="/org/gnome/shell/extensions/claude-code-status/">
|
||||||
|
<key name="panel-box" type="s">
|
||||||
|
<choices>
|
||||||
|
<choice value="left"/>
|
||||||
|
<choice value="center"/>
|
||||||
|
<choice value="right"/>
|
||||||
|
</choices>
|
||||||
|
<default>'center'</default>
|
||||||
|
<summary>Which panel box the chips live in</summary>
|
||||||
|
<description>The centre box holds the clock and is the thing you already glance at, which is why the chips start there. The right box is the system's own state area; the left one sits after the activities button and the app menu.</description>
|
||||||
|
</key>
|
||||||
|
<key name="panel-position" type="i">
|
||||||
|
<default>1</default>
|
||||||
|
<range min="0" max="10"/>
|
||||||
|
<summary>Index within that box</summary>
|
||||||
|
<description>0 puts the chips first, before everything else in the box; the default of 1 puts them immediately right of the clock, the centre box's only other occupant. An index past the end of the box lands at the end.</description>
|
||||||
|
</key>
|
||||||
<key name="show-project-name" type="b">
|
<key name="show-project-name" type="b">
|
||||||
<default>true</default>
|
<default>true</default>
|
||||||
<summary>Label each chip with its project</summary>
|
<summary>Label each chip with its project</summary>
|
||||||
@@ -9,18 +25,30 @@
|
|||||||
</key>
|
</key>
|
||||||
<key name="abbreviate-names" type="b">
|
<key name="abbreviate-names" type="b">
|
||||||
<default>true</default>
|
<default>true</default>
|
||||||
<summary>Shorten project names to three characters</summary>
|
<summary>Shorten project names on the chips</summary>
|
||||||
<description>Chips show initials ("dev-skills" becomes "ds") so a row of sessions stays narrow. Sessions that would collide, including two in the same project, get a digit by seniority: the older one keeps its label. Turn off to show full project names.</description>
|
<description>Chips show initials ("dev-skills" becomes "ds") so a row of sessions stays narrow. Sessions that would collide, including two in the same project, get a digit by seniority: the older one keeps its label. Turn off to show full project names.</description>
|
||||||
</key>
|
</key>
|
||||||
|
<key name="abbrev-length" type="i">
|
||||||
|
<default>3</default>
|
||||||
|
<range min="3" max="10"/>
|
||||||
|
<summary>How many characters a shortened label may use</summary>
|
||||||
|
<description>Three is enough to tell initials apart and narrow enough that a row of chips does not push the clock about. Longer labels read more like the project name; a disambiguating digit still eats into the label rather than extending past this width, so every chip stays the same size. Below three, distinct projects start sharing a label.</description>
|
||||||
|
</key>
|
||||||
|
<key name="max-chips" type="i">
|
||||||
|
<default>3</default>
|
||||||
|
<range min="1" max="12"/>
|
||||||
|
<summary>How many sessions get a chip</summary>
|
||||||
|
<description>Chips are ordered by urgency, so the ones shown are the ones that need you soonest; the rest are counted as "+N". Keeps the row from pushing the clock off centre when many sessions are open.</description>
|
||||||
|
</key>
|
||||||
<key name="show-age" type="b">
|
<key name="show-age" type="b">
|
||||||
<default>true</default>
|
<default>true</default>
|
||||||
<summary>Show how long the session has been in this state</summary>
|
<summary>Show how long each working session has been at it</summary>
|
||||||
<description>How long a session has been working, or waiting, is what decides whether to switch to it now or leave it running.</description>
|
<description>A counter on every chip that is working, which is what says whether to wait for it or go and look: five minutes is a build, forty is a session stuck on something. The idle states get no counter — their age only says how long ago you stopped looking.</description>
|
||||||
</key>
|
</key>
|
||||||
<key name="zellij-integration" type="b">
|
<key name="zellij-integration" type="b">
|
||||||
<default>true</default>
|
<default>true</default>
|
||||||
<summary>Resolve zellij tab names</summary>
|
<summary>Resolve zellij tab names</summary>
|
||||||
<description>Look up which zellij tab each session runs in, show it in the menu, and let a click switch to that tab. Requires the zellij command; harmless when it is absent.</description>
|
<description>Look up which zellij tab each session runs in and name it in the menu, which answers "which terminal" better than a path does. Requires the zellij command; harmless when it is absent.</description>
|
||||||
</key>
|
</key>
|
||||||
</schema>
|
</schema>
|
||||||
</schemalist>
|
</schemalist>
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
font-feature-settings: "tnum";
|
font-feature-settings: "tnum";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ccs-overflow {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-feature-settings: "tnum";
|
||||||
|
}
|
||||||
|
|
||||||
.ccs-summary {
|
.ccs-summary {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ check('camelCase counts as segments', 'om', abbreviate('outlineMcp'));
|
|||||||
check('digits survive', 'p2', abbreviate('proj_2'));
|
check('digits survive', 'p2', abbreviate('proj_2'));
|
||||||
check('empty name does not crash', '?', abbreviate(''));
|
check('empty name does not crash', '?', abbreviate(''));
|
||||||
|
|
||||||
|
// --- a wider label ---------------------------------------------------------
|
||||||
|
// More initials, not a longer prefix: the whole point of initials is keeping
|
||||||
|
// "dev-skills" and "dev-conventions" apart, and a prefix at any width does not.
|
||||||
|
check('a wider label takes more initials', 'ccge',
|
||||||
|
abbreviate('claude-code-gnome-extension', 4));
|
||||||
|
check('and stops at the segments it has', 'ds', abbreviate('dev-skills', 6));
|
||||||
|
check('a single word gets more of itself', 'jellyb', abbreviate('jellybit', 6));
|
||||||
|
check('the default is still three', 'ccg', abbreviate('claude-code-gnome-extension'));
|
||||||
|
check('a nonsense width falls back to the default', 'ccg',
|
||||||
|
abbreviate('claude-code-gnome-extension', 'wide'));
|
||||||
|
|
||||||
// --- collisions ------------------------------------------------------------
|
// --- collisions ------------------------------------------------------------
|
||||||
const two = [
|
const two = [
|
||||||
{ sessionId: 'a', since: 100, base: 'dev-skills' },
|
{ sessionId: 'a', since: 100, base: 'dev-skills' },
|
||||||
@@ -70,6 +81,21 @@ check('every label fits in three characters', [3], lengths);
|
|||||||
check('twelve sessions in one project are all distinct',
|
check('twelve sessions in one project are all distinct',
|
||||||
12, new Set(wide.values()).size);
|
12, new Set(wide.values()).size);
|
||||||
|
|
||||||
|
// A digit still eats into the label instead of extending past the width, which
|
||||||
|
// is what keeps a row of chips from rippling when one of them gains a digit.
|
||||||
|
const wider = assignChips(many, new Map(), 5);
|
||||||
|
check('a wider run holds its own width',
|
||||||
|
[5], [...new Set([...wider.values()].map(l => l.length))]);
|
||||||
|
check('and stays distinct', 12, new Set(wider.values()).size);
|
||||||
|
check('the oldest keeps the clean label', 'umbar', wider.get('s0'));
|
||||||
|
check('the next one gives up a character', 'umba2', wider.get('s1'));
|
||||||
|
|
||||||
|
// Stickiness outranks the width: labels already handed out are kept as they
|
||||||
|
// are. The indicator drops the map when the setting changes, which is the only
|
||||||
|
// way a label is allowed to move.
|
||||||
|
const kept = assignChips(many, wide, 5);
|
||||||
|
check('an existing label is not re-cut', 'umb', kept.get('s0'));
|
||||||
|
|
||||||
out(failures ? `\n${failures} failure(s)` : '\nall passed');
|
out(failures ? `\n${failures} failure(s)` : '\nall passed');
|
||||||
if (typeof imports !== 'undefined')
|
if (typeof imports !== 'undefined')
|
||||||
imports.system.exit(failures ? 1 : 0);
|
imports.system.exit(failures ? 1 : 0);
|
||||||
|
|||||||
+99
-19
@@ -32,10 +32,69 @@ ev() { # event [extra json]
|
|||||||
echo "{\"session_id\":\"$SID\",\"hook_event_name\":\"$1\",\"cwd\":\"/tmp/proj\"${2:+,$2}}"
|
echo "{\"session_id\":\"$SID\",\"hook_event_name\":\"$1\",\"cwd\":\"/tmp/proj\"${2:+,$2}}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- identifying the claude process ----------------------------------------
|
||||||
|
# The hook is spawned as `/bin/sh -c /.../claude-status-hook.py`, so its
|
||||||
|
# parent's command line contains "claude" in a path without being claude.
|
||||||
|
# Matching on the raw string latches onto that shell, which exits at once.
|
||||||
|
python3 - "$HOOK" <<'PY'
|
||||||
|
import importlib.util, sys
|
||||||
|
spec = importlib.util.spec_from_file_location("h", sys.argv[1])
|
||||||
|
h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h)
|
||||||
|
cases = [
|
||||||
|
(["/bin/sh", "-c", "/home/u/claude-code-gnome-extension/hooks/claude-status-hook.py"], False),
|
||||||
|
(["/home/u/.local/bin/claude", "--resume"], True),
|
||||||
|
(["bash", "/home/u/bin/claude"], True),
|
||||||
|
(["node", "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"], True),
|
||||||
|
(["/home/u/bin/zellij", "--server", "/run/user/1000/zellij/x"], False),
|
||||||
|
(["nvim", "/home/u/.claude/settings.json"], False),
|
||||||
|
]
|
||||||
|
bad = 0
|
||||||
|
for argv, want in cases:
|
||||||
|
got = h.looks_like_claude(argv)
|
||||||
|
print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (" ".join(argv)[:46], got))
|
||||||
|
bad += got != want
|
||||||
|
|
||||||
|
# A one-shot run has no input line and no human in front of it. The prompt is
|
||||||
|
# an ordinary argument, so a prompt that merely mentions -p must not count.
|
||||||
|
headless = [
|
||||||
|
(["claude"], False),
|
||||||
|
(["claude", "--resume"], False),
|
||||||
|
(["claude", "-p", "summarise this"], True),
|
||||||
|
(["claude", "--print", "--output-format", "stream-json"], True),
|
||||||
|
(["claude", "explain what -p does"], False),
|
||||||
|
(["claude", "--permission-mode", "plan"], False),
|
||||||
|
]
|
||||||
|
for argv, want in headless:
|
||||||
|
got = h.is_headless(argv)
|
||||||
|
print(("ok " if got == want else "FAIL ") + "headless %r -> %s" % (" ".join(argv)[:46], got))
|
||||||
|
bad += got != want
|
||||||
|
sys.exit(1 if bad else 0)
|
||||||
|
PY
|
||||||
|
check "command lines classified correctly" "0" "$?"
|
||||||
|
|
||||||
|
# End to end, through /proc rather than through the classifier: a fake "claude"
|
||||||
|
# runs the hook as a child, exactly as the real one does.
|
||||||
|
FAKE="$XDG_STATE_HOME/claude"
|
||||||
|
printf '#!/bin/sh\n"$1"\n' > "$FAKE"
|
||||||
|
chmod +x "$FAKE"
|
||||||
|
printf '{"session_id":"headless","hook_event_name":"SessionStart","cwd":"/tmp/p"}' \
|
||||||
|
| "$FAKE" "$HOOK" -p
|
||||||
|
[ -e "$DIR/headless.json" ]; check "claude -p leaves no state file" "1" "$?"
|
||||||
|
|
||||||
|
printf '{"session_id":"headless","hook_event_name":"SessionStart","cwd":"/tmp/p"}' \
|
||||||
|
| "$FAKE" "$HOOK"
|
||||||
|
[ -e "$DIR/headless.json" ]; check "the same session without -p is recorded" "0" "$?"
|
||||||
|
rm -f "$DIR/headless.json" "$DIR/headless.json.lock"
|
||||||
|
|
||||||
# --- state machine ---------------------------------------------------------
|
# --- state machine ---------------------------------------------------------
|
||||||
emit "$(ev SessionStart '"source":"startup"')"
|
emit "$(ev SessionStart '"source":"startup"')"
|
||||||
check "SessionStart -> waiting" "waiting" "$(field state)"
|
check "SessionStart -> waiting" "waiting" "$(field state)"
|
||||||
|
|
||||||
|
# Pins the recorded pid to one process, so a state file that outlives a reboot
|
||||||
|
# cannot be revived by whatever inherits that pid number next.
|
||||||
|
start=$(field pid_start)
|
||||||
|
check "process start time recorded" "yes" "$([ -n "$start" ] && [ "$start" != "0" ] && echo yes || echo no)"
|
||||||
|
|
||||||
emit "$(ev UserPromptSubmit '"prompt":"hi"')"
|
emit "$(ev UserPromptSubmit '"prompt":"hi"')"
|
||||||
check "UserPromptSubmit -> busy" "busy" "$(field state)"
|
check "UserPromptSubmit -> busy" "busy" "$(field state)"
|
||||||
|
|
||||||
@@ -90,39 +149,57 @@ check "compaction does not reset the clock" "$since_before" "$(field since)"
|
|||||||
# The scenario this exists for: you ask for a batch, the main agent launches it
|
# The scenario this exists for: you ask for a batch, the main agent launches it
|
||||||
# and ends its turn, and the session waits for the results to consolidate them.
|
# and ends its turn, and the session waits for the results to consolidate them.
|
||||||
# It is working, not waiting for you, and must not call you over.
|
# It is working, not waiting for you, and must not call you over.
|
||||||
|
#
|
||||||
|
# What is running is not counted from starts and stops but taken from the list
|
||||||
|
# the events carry, exactly as measured on a live session: Stop and
|
||||||
|
# SubagentStop bring "background_tasks", the rest of the events bring nothing.
|
||||||
|
bg() { # running-subagent-count -> the snapshot as those events carry it
|
||||||
|
tasks=""
|
||||||
|
for i in $(seq 0 $(($1 - 1))); do
|
||||||
|
tasks="$tasks${tasks:+,}{\"id\":\"sub-$i\",\"type\":\"subagent\",\"status\":\"running\"}"
|
||||||
|
done
|
||||||
|
echo "\"background_tasks\":[$tasks]"
|
||||||
|
}
|
||||||
|
|
||||||
emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')"
|
emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')"
|
||||||
check "a new turn resets the count" "0" "$(field agents)"
|
emit "$(ev Stop "$(bg 2)")"
|
||||||
|
|
||||||
emit "$(ev PreToolUse '"tool_name":"Agent"')"
|
|
||||||
emit "$(ev PreToolUse '"tool_name":"Agent"')"
|
|
||||||
check "two launches counted" "2" "$(field agents)"
|
|
||||||
|
|
||||||
emit "$(ev PreToolUse '"tool_name":"TaskCreate"')"
|
|
||||||
check "a tool that merely starts with Task is not a subagent" "2" "$(field agents)"
|
|
||||||
|
|
||||||
emit "$(ev Stop)"
|
|
||||||
check "main agent stopping does not free a running batch" "busy" "$(field state)"
|
check "main agent stopping does not free a running batch" "busy" "$(field state)"
|
||||||
|
check "the snapshot is what gets stored" "2" "$(field agents)"
|
||||||
|
|
||||||
emit "$(ev SubagentStop '"agent_id":"sub-1","agent_type":"general-purpose"')"
|
emit "$(ev SubagentStop "\"agent_id\":\"sub-0\",\"agent_type\":\"general-purpose\",$(bg 1)")"
|
||||||
check "one down, still working" "busy" "$(field state)"
|
check "one down, still working" "busy" "$(field state)"
|
||||||
check "count decremented" "1" "$(field agents)"
|
check "the count follows the snapshot" "1" "$(field agents)"
|
||||||
|
|
||||||
emit "$(ev SubagentStop '"agent_id":"sub-2","agent_type":"general-purpose"')"
|
# The regression this replaced counting for: a subagent's own subagent stops,
|
||||||
|
# and its SubagentStop reaches this session just like a top-level one -- while
|
||||||
|
# its *launch* never did, because that event carried agent_id and was dropped.
|
||||||
|
# Subtracting it freed a session whose batch was still running.
|
||||||
|
emit "$(ev SubagentStop "\"agent_id\":\"nested-1\",\"agent_type\":\"\",$(bg 1)")"
|
||||||
|
check "a nested subagent stopping does not free the batch" "busy" "$(field state)"
|
||||||
|
check "and does not touch the count" "1" "$(field agents)"
|
||||||
|
|
||||||
|
emit "$(ev SubagentStop "\"agent_id\":\"sub-1\",$(bg 0)")"
|
||||||
check "last subagent finishing frees the session" "waiting" "$(field state)"
|
check "last subagent finishing frees the session" "waiting" "$(field state)"
|
||||||
check "count back to zero" "0" "$(field agents)"
|
check "count back to zero" "0" "$(field agents)"
|
||||||
|
|
||||||
|
# A background shell is a background task too, and must not be mistaken for
|
||||||
|
# work: a dev server left running would pin the chip at "working" for good.
|
||||||
|
emit "$(ev UserPromptSubmit '"prompt":"serve"')"
|
||||||
|
emit "$(ev Stop '"background_tasks":[{"id":"b1","type":"bash","status":"running"}]')"
|
||||||
|
check "a background shell is not a subagent" "waiting" "$(field state)"
|
||||||
|
|
||||||
# A batch that finishes while the main agent is still mid-turn must not free it.
|
# A batch that finishes while the main agent is still mid-turn must not free it.
|
||||||
emit "$(ev UserPromptSubmit '"prompt":"again"')"
|
emit "$(ev UserPromptSubmit '"prompt":"again"')"
|
||||||
emit "$(ev PreToolUse '"tool_name":"Agent"')"
|
emit "$(ev SubagentStop "\"agent_id\":\"sub-3\",$(bg 0)")"
|
||||||
emit "$(ev SubagentStop '"agent_id":"sub-3"')"
|
|
||||||
check "batch done mid-turn leaves the session working" "busy" "$(field state)"
|
check "batch done mid-turn leaves the session working" "busy" "$(field state)"
|
||||||
|
|
||||||
# An idle nudge while a batch runs must not claim the session is free either.
|
# An idle nudge while a batch runs must not claim the session is free either.
|
||||||
emit "$(ev PreToolUse '"tool_name":"Agent"')"
|
# It carries no snapshot of its own, so this also checks the stored one stands.
|
||||||
emit "$(ev Stop)"
|
emit "$(ev Stop "$(bg 1)")"
|
||||||
emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')"
|
emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')"
|
||||||
check "idle nudge cannot free a running batch" "busy" "$(field state)"
|
check "idle nudge cannot free a running batch" "busy" "$(field state)"
|
||||||
emit "$(ev SubagentStop '"agent_id":"sub-4"')"
|
check "an event without a snapshot leaves the count alone" "1" "$(field agents)"
|
||||||
|
emit "$(ev SubagentStop "\"agent_id\":\"sub-4\",$(bg 0)")"
|
||||||
check "and it frees properly once the batch ends" "waiting" "$(field state)"
|
check "and it frees properly once the batch ends" "waiting" "$(field state)"
|
||||||
|
|
||||||
# --- concurrency -----------------------------------------------------------
|
# --- concurrency -----------------------------------------------------------
|
||||||
@@ -164,7 +241,10 @@ check "hook proceeds once the lock is released" "waiting" "$(field state)"
|
|||||||
# --- teardown --------------------------------------------------------------
|
# --- teardown --------------------------------------------------------------
|
||||||
emit "$(ev SessionEnd '"reason":"other"')"
|
emit "$(ev SessionEnd '"reason":"other"')"
|
||||||
[ -e "$FILE" ]; check "SessionEnd removes the state file" "1" "$?"
|
[ -e "$FILE" ]; check "SessionEnd removes the state file" "1" "$?"
|
||||||
[ -e "$FILE.lock" ]; check "SessionEnd removes the lock file" "1" "$?"
|
# The lock deliberately stays. Unlinking it while holding it would drop mutual
|
||||||
|
# exclusion for any hook already blocked on the old inode; the reader sweeps it
|
||||||
|
# once it is orphaned and old.
|
||||||
|
[ -e "$FILE.lock" ]; check "SessionEnd keeps the lock inode" "0" "$?"
|
||||||
|
|
||||||
rm -rf "$XDG_STATE_HOME"
|
rm -rf "$XDG_STATE_HOME"
|
||||||
if [ "$failures" -gt 0 ]; then
|
if [ "$failures" -gt 0 ]; then
|
||||||
|
|||||||
+38
-8
@@ -36,10 +36,13 @@ const schemas = Gio.SettingsSchemaSource.new_from_directory(
|
|||||||
const { default: Prefs } = await import(`file://${tmp}/prefs.js`);
|
const { default: Prefs } = await import(`file://${tmp}/prefs.js`);
|
||||||
const prefs = new Prefs();
|
const prefs = new Prefs();
|
||||||
Object.defineProperty(prefs, 'path', { value: EXT }); // a getter upstream
|
Object.defineProperty(prefs, 'path', { value: EXT }); // a getter upstream
|
||||||
prefs.getSettings = () => new Gio.Settings({
|
// A memory backend, not the default one: the test writes a key to check the
|
||||||
settings_schema: schemas.lookup(
|
// hand-rolled combo binding, and it has no business touching the settings of
|
||||||
'org.gnome.shell.extensions.claude-code-status', true),
|
// whoever is running it.
|
||||||
});
|
const backend = Gio.memory_settings_backend_new();
|
||||||
|
prefs.getSettings = () => Gio.Settings.new_full(
|
||||||
|
schemas.lookup('org.gnome.shell.extensions.claude-code-status', true),
|
||||||
|
backend, '/org/gnome/shell/extensions/claude-code-status/');
|
||||||
|
|
||||||
const window = new Adw.PreferencesWindow();
|
const window = new Adw.PreferencesWindow();
|
||||||
prefs.fillPreferencesWindow(window);
|
prefs.fillPreferencesWindow(window);
|
||||||
@@ -48,7 +51,8 @@ const rows = [];
|
|||||||
const walk = widget => {
|
const walk = widget => {
|
||||||
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
|
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
|
||||||
const type = c.constructor.$gtype.name;
|
const type = c.constructor.$gtype.name;
|
||||||
if (type === 'AdwSwitchRow' || type === 'AdwActionRow')
|
if (type === 'AdwSwitchRow' || type === 'AdwSpinRow' ||
|
||||||
|
type === 'AdwComboRow' || type === 'AdwActionRow')
|
||||||
rows.push({ type, title: c.title, subtitle: c.subtitle });
|
rows.push({ type, title: c.title, subtitle: c.subtitle });
|
||||||
walk(c);
|
walk(c);
|
||||||
}
|
}
|
||||||
@@ -65,11 +69,37 @@ function check(name, condition, detail = '') {
|
|||||||
for (const row of rows)
|
for (const row of rows)
|
||||||
print(` ${row.type.replace('Adw', '').padEnd(10)} ${row.title}`);
|
print(` ${row.type.replace('Adw', '').padEnd(10)} ${row.title}`);
|
||||||
|
|
||||||
// One switch per settings key, so a key added without a row is caught.
|
// One control per settings key, so a key added without a row to change it is
|
||||||
|
// caught here rather than by a user wondering why nothing happens.
|
||||||
const keys = schemas.lookup('org.gnome.shell.extensions.claude-code-status', true)
|
const keys = schemas.lookup('org.gnome.shell.extensions.claude-code-status', true)
|
||||||
.list_keys().length;
|
.list_keys().length;
|
||||||
const switches = rows.filter(r => r.type === 'AdwSwitchRow').length;
|
const controls = rows.filter(
|
||||||
check('a switch for every settings key', switches === keys, `${switches} of ${keys}`);
|
r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow' ||
|
||||||
|
r.type === 'AdwComboRow').length;
|
||||||
|
check('a control for every settings key', controls === keys, `${controls} of ${keys}`);
|
||||||
|
|
||||||
|
// The combo is bound by hand rather than through Gio.Settings.bind, so the
|
||||||
|
// binding is worth a test: it must start on the stored value and write back
|
||||||
|
// the value, not the row index.
|
||||||
|
const combo = [];
|
||||||
|
const findCombos = widget => {
|
||||||
|
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
|
||||||
|
if (c.constructor.$gtype.name === 'AdwComboRow')
|
||||||
|
combo.push(c);
|
||||||
|
findCombos(c);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
findCombos(window);
|
||||||
|
const settings = prefs.getSettings();
|
||||||
|
check('the panel box row exists', combo.length === 1, `${combo.length} combo rows`);
|
||||||
|
if (combo.length) {
|
||||||
|
check('starts on the stored value',
|
||||||
|
combo[0].selected === 1, `selected ${combo[0].selected}`); // 'center'
|
||||||
|
combo[0].selected = 2;
|
||||||
|
check('writing back stores the value, not the index',
|
||||||
|
settings.get_string('panel-box') === 'right',
|
||||||
|
settings.get_string('panel-box'));
|
||||||
|
}
|
||||||
|
|
||||||
// The hook status line is the reason this page is worth opening at all: a
|
// The hook status line is the reason this page is worth opening at all: a
|
||||||
// silent panel looks the same whether nothing runs or nothing is installed.
|
// silent panel looks the same whether nothing runs or nothing is installed.
|
||||||
|
|||||||
+23
-3
@@ -27,11 +27,12 @@ function check(name, condition, detail = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = GLib.get_real_time() / 1e6;
|
const now = GLib.get_real_time() / 1e6;
|
||||||
function write(id, state, cwd, agoSeconds, pid) {
|
function write(id, state, cwd, agoSeconds, pid, pidStart = 0) {
|
||||||
const payload = {
|
const payload = {
|
||||||
session_id: id, state, cwd, since: now - agoSeconds,
|
session_id: id, state, cwd, since: now - agoSeconds,
|
||||||
event_ts: now, pid, event: 'test', notification_type: '',
|
event_ts: now, pid, pid_start: pidStart, event: 'test',
|
||||||
message: '', zellij_session: 'ztest', zellij_pane: '1', transcript: '',
|
notification_type: '', message: '', zellij_session: 'ztest',
|
||||||
|
zellij_pane: '1', transcript: '',
|
||||||
};
|
};
|
||||||
GLib.file_set_contents(
|
GLib.file_set_contents(
|
||||||
GLib.build_filenamev([STATE, `${id}.json`]), JSON.stringify(payload));
|
GLib.build_filenamev([STATE, `${id}.json`]), JSON.stringify(payload));
|
||||||
@@ -50,6 +51,17 @@ write('s-dead', 'waiting', '/home/u/proj-dead', 5, deadPid);
|
|||||||
// Written by the older hook, which had a fourth state. Files like this
|
// Written by the older hook, which had a fourth state. Files like this
|
||||||
// survive an upgrade in a session that was already open.
|
// survive an upgrade in a session that was already open.
|
||||||
write('s-legacy', 'idle', '/home/u/proj-legacy', 5, livePid);
|
write('s-legacy', 'idle', '/home/u/proj-legacy', 5, livePid);
|
||||||
|
// Survived a reboot: the pid exists again, but belongs to something else now.
|
||||||
|
// Without an identity check this sits in the panel forever as a live session.
|
||||||
|
write('s-ghost', 'waiting', '/home/u/proj-ghost', 99999, livePid, 1);
|
||||||
|
// Left by a session that ended: the hook keeps the lock inode deliberately,
|
||||||
|
// so nobody but the reader would ever clear it.
|
||||||
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 's-gone.json.lock']), '');
|
||||||
|
GLib.spawn_command_line_sync(
|
||||||
|
`touch -d '1 hour ago' ${GLib.build_filenamev([STATE, 's-gone.json.lock'])}`);
|
||||||
|
// A state file far larger than any real one must not be read at all.
|
||||||
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 'huge.json']),
|
||||||
|
`{"session_id":"huge","state":"waiting","pid":1,"cwd":"${'x'.repeat(70000)}"}`);
|
||||||
GLib.file_set_contents(GLib.build_filenamev([STATE, 'notes.txt']), 'ignored');
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 'notes.txt']), 'ignored');
|
||||||
GLib.file_set_contents(GLib.build_filenamev([STATE, 'half.json']), '{"broken');
|
GLib.file_set_contents(GLib.build_filenamev([STATE, 'half.json']), '{"broken');
|
||||||
|
|
||||||
@@ -67,6 +79,10 @@ store.connect('changed', () => {
|
|||||||
stateRank('waiting') < stateRank('busy'));
|
stateRank('waiting') < stateRank('busy'));
|
||||||
|
|
||||||
check('dead session dropped', !s.some(x => x.sessionId === 's-dead'));
|
check('dead session dropped', !s.some(x => x.sessionId === 's-dead'));
|
||||||
|
check('reused pid from a previous boot dropped',
|
||||||
|
!s.some(x => x.sessionId === 's-ghost'));
|
||||||
|
check('and its file removed',
|
||||||
|
!GLib.file_test(GLib.build_filenamev([STATE, 's-ghost.json']), GLib.FileTest.EXISTS));
|
||||||
check('truncated file skipped, others survive', s.length === 5,
|
check('truncated file skipped, others survive', s.length === 5,
|
||||||
`got ${s.length}: ${s.map(x => x.sessionId).join(',')}`);
|
`got ${s.length}: ${s.map(x => x.sessionId).join(',')}`);
|
||||||
check('non-json ignored', !s.some(x => x.sessionId.includes('notes')));
|
check('non-json ignored', !s.some(x => x.sessionId.includes('notes')));
|
||||||
@@ -82,6 +98,10 @@ store.connect('changed', () => {
|
|||||||
s[3]?.sessionId === 's-legacy', s[3]?.sessionId);
|
s[3]?.sessionId === 's-legacy', s[3]?.sessionId);
|
||||||
check('busy after waiting', s[4]?.sessionId === 's-busy', s[4]?.sessionId);
|
check('busy after waiting', s[4]?.sessionId === 's-busy', s[4]?.sessionId);
|
||||||
|
|
||||||
|
check('oversized state file not loaded', !s.some(x => x.sessionId === 'huge'));
|
||||||
|
check('orphaned lock swept',
|
||||||
|
!GLib.file_test(GLib.build_filenamev([STATE, 's-gone.json.lock']), GLib.FileTest.EXISTS));
|
||||||
|
|
||||||
check('dead session file removed from disk',
|
check('dead session file removed from disk',
|
||||||
!GLib.file_test(GLib.build_filenamev([STATE, 's-dead.json']), GLib.FileTest.EXISTS));
|
!GLib.file_test(GLib.build_filenamev([STATE, 's-dead.json']), GLib.FileTest.EXISTS));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user