Compare commits

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

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

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

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

PreToolUse is no longer registered: counting was the only thing it was for.
It stays listed as a legacy event so that both install and uninstall sweep
it out of settings.json rather than leaving it there to spawn the hook on
every agent launch for nothing.
2026-08-23 09:19:30 +03:00
av f8e85d5703 Put the clock on the working chips, and on all of them
The counter rode on the first chip, whichever state it happened to be in.
It now shows on every chip that is working and on no other: how long a
session has been at it is the thing that decides whether to wait for it,
where the age of an idle one only says how long ago you stopped looking.

The setting keeps its key and changes its name to "Show working time".

CLAUDE.md is new, and its first rule is the one broken twice: writing to
dconf from a shell attached to the live session bus wipes settings that
cannot be recovered.
2026-08-10 17:21:43 +03:00
av be69bec17f Make the panel position and the label width settings
Two things that were constants and had no business being constants.

Placement: the box (left, centre, right) and the index within it. The
default is unchanged -- centre, index 1, immediately right of the clock --
and an index past the end of a box lands at the end, so a large one means
"last". Applied at once, no reload.

Moving is done by rebuilding the indicator rather than by moving the actor.
addToStatusArea is what registers it under the uuid and there is no
documented call to move one between boxes; everything else reaches into
Main.panel's private boxes. It costs one re-read of a few small state files
and only when the setting is touched.

Label width: three characters by default, settable up to ten, not down.
Three is enough to keep initials apart and narrow enough not to shove the
clock about; below three, distinct projects start sharing a label. A wider
label takes more initials rather than a longer prefix -- a prefix collapses
dev-skills and dev-conventions at any width -- so dev-skills stays "ds"
however wide the setting, while claude-code-gnome-extension becomes "ccge"
at four. A disambiguating digit still eats into the label instead of
extending past the width, so a chip that gains one does not push the row.

Sticky labels work against that setting: kept labels are kept whatever
width they were cut at, so widening would leave every session on screen at
its old width until it ended. The width is therefore the one thing that
discards the map -- a relabelling that was asked for is not a label moving
under your hand.

The combo row is 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. Only the write direction is
wired up, and the test covers it, because a row that stores its index
instead of its value looks fine until the shell reads the key.

Fixed on the way, found by watching the centre box grow 2 -> 3 -> 4 -> 5
across four moves: PanelMenu.ButtonBox connects `this._onDestroy.bind(this)`
in its _init, and its _onDestroy is what destroys the container -- the
St.Bin the panel box actually holds. The name resolves through the
prototype chain, so this extension's own _onDestroy had been silently
replacing the shell's since the beginning, leaving an empty container in
the panel on every teardown. Renamed to _teardown; the box now stays at two
children across moves and across enable/disable cycles.

Verified in a nested shell on a copy of the extension carrying temporary
logging, since the shell refuses screenshots to non-portal callers: every
box, indices 0, 1 and 9, and widths 3, 5, 8, 10 and back, with no JS errors
and no leftover actors.
2026-08-09 21:56:10 +03:00
av 67d7b14cf5 Keep headless runs out of the panel
`claude -p` was showing up as a session. It prints one answer and exits:
there is no input line, it cannot be blocked on you, and there is nowhere
to walk over to. A script that runs a few dozen of them turned the panel
into a flicker of chips that were gone before they could be read. The Agent
SDK and editor integrations drive claude the same way and are covered by
the same rule.

The flag is looked for in the arguments of the already-identified claude
process, by exact token, so nothing new has to be discovered -- the pid was
resolved on every event anyway. That resolution moved from apply_event up
into main, which is where the decision has to be made: a headless run is
dropped before the state file is touched at all, so it never creates one
and correspondingly never deletes one on SessionEnd. Its events still reach
the debug log, otherwise "why is my session missing from the panel" would
have nothing to answer with.

Arguments are now read by splitting /proc/<pid>/cmdline on its NUL
separators rather than on spaces. A prompt is an ordinary argument, and
`claude "when do I need -p"` is an interactive session that keeps its chip;
the old space-joined string could not tell the two apart. looks_like_claude
takes the list too, which is what it always wanted -- it was splitting the
joined string back apart itself.

Verified end to end as well as in the classifier: a fake claude runs the
hook as a child through /proc, with -p leaving no file and without -p
leaving one. A real `claude -p` against the installed hook added nothing to
the state directory.
2026-08-09 21:35:04 +03:00
12 changed files with 589 additions and 167 deletions
+47
View File
@@ -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`.
+97 -28
View File
@@ -42,19 +42,46 @@ Code ждёт меня прямо сейчас?**
текстом `«Claude needs your permission»`. Различить их можно было бы только через текстом `«Claude needs your permission»`. Различить их можно было бы только через
`PreToolUse` — ценой записи файла на каждый вызов инструмента. `PreToolUse` — ценой записи файла на каждый вызов инструмента.
Чипов показывается три (настраивается), остальные сворачиваются в `+N`. Панель Чипов показывается три (настраивается), остальные сворачиваются в `+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) — инициалы, иначе
первые буквы.
| Проект | Чип | | Проект | Чип |
|---|---| |---|---|
@@ -80,6 +107,14 @@ Code ждёт меня прямо сейчас?**
закрылась сессия, из-за которой появилась цифра. Метка, переехавшая под закрылась сессия, из-за которой появилась цифра. Метка, переехавшая под
рукой, хуже метки с цифрой, которая уже не выглядит нужной. рукой, хуже метки с цифрой, которая уже не выглядит нужной.
Более широкая метка берёт **больше инициалов**, а не более длинный префикс — по
той же причине. Поэтому `dev-skills` останется `ds` при любой ширине, а
`claude-code-gnome-extension` при четырёх знаках станет `ccge`.
Ширина — единственное, что сбрасывает закреплённые метки: перерисовка, о которой
попросили сами, это не метка, уехавшая под рукой. Смена ширины перелейблит все
сессии разом.
Сокращение отключается в настройках — тогда в чипах полные имена проектов. Сокращение отключается в настройках — тогда в чипах полные имена проектов.
В меню строка начинается с той же метки, чтобы соответствие «`ds` — это В меню строка начинается с той же метки, чтобы соответствие «`ds` — это
dev-skills» читалось, а не угадывалось. dev-skills» читалось, а не угадывалось.
@@ -127,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` не избыточен: это единственное событие, срабатывающее после выдачи
@@ -137,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`, а событие старше
@@ -146,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`. В отладочный
лог (см. ниже) её события при этом попадают: иначе «почему сессии нет в панели»
было бы нечем объяснить.
## Сабагенты ## Сабагенты
Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и
@@ -154,31 +209,45 @@ 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
+40 -6
View File
@@ -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();
} }
} }
+109 -52
View File
@@ -48,10 +48,15 @@ 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 # Events that mean the question has been dealt with. Anything else leaves a
# "blocked" session blocked: a subagent finishing, or the next tool starting, # "blocked" session blocked: a subagent finishing, or the next tool starting,
@@ -136,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:
@@ -169,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):
@@ -188,18 +233,16 @@ def parent_of(pid):
return 0 return 0
def looks_like_claude(cmdline): def looks_like_claude(argv):
"""Is this command line the claude binary itself? """Is this argument list the claude binary itself?
Matched per argument, never against the raw string. The hook is spawned as Matched per argument, never against the joined string. The hook is spawned
`/bin/sh -c /.../claude-status-hook.py`, so its parent's command line as `/bin/sh -c /.../claude-status-hook.py`, so its parent's command line
contains the word "claude" -- in a path -- without being claude at all. contains the word "claude" -- in a path -- without being claude at all.
Latching onto that shell records a pid that exits milliseconds later, and Latching onto that shell records a pid that exits milliseconds later, and
the session then flickers in and out of the panel. the session then flickers in and out of the panel.
""" """
for token in cmdline.split(" "): for token in argv:
if not token:
continue
base = os.path.basename(token) base = os.path.basename(token)
if base == "claude": if base == "claude":
return True return True
@@ -209,12 +252,28 @@ def looks_like_claude(cmdline):
return False return False
def find_claude_pid(): def is_headless(argv):
"""Nearest ancestor that is the claude process itself, or 0 if unknown. """Was this claude started to print one answer and exit?
The hook is spawned through a shell, so the immediate parent is not claude. `claude -p` has no input line and nobody sitting in front of it, so it can
Walking beyond a handful of levels risks latching onto an outer claude when never be blocked on you and there is nothing to walk over to. Left in, a
one session drives another, so the search stops early. 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 process is gone, and the obvious fallback -- the shell that spawned whose process is gone, and the obvious fallback -- the shell that spawned
@@ -224,10 +283,11 @@ def find_claude_pid():
for _ in range(6): for _ in range(6):
if pid <= 1: if pid <= 1:
break break
if looks_like_claude(read_cmdline(pid)): argv = read_argv(pid)
return pid if looks_like_claude(argv):
return pid, argv
pid = parent_of(pid) pid = parent_of(pid)
return 0 return 0, []
def pid_start_time(pid): def pid_start_time(pid):
@@ -324,7 +384,7 @@ def open_lock(path):
raise raise
def apply_event(event, state, path, now): 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
@@ -372,25 +432,17 @@ def apply_event(event, state, path, now):
previous_ts = 0 # corrupt, and trusting it would freeze this session previous_ts = 0 # corrupt, and trusting it would freeze this session
stale = previous_ts > now stale = previous_ts > now
# The subagent count is different in kind: the mutations are deltas, and # Applied whether or not this event lost the race above: a snapshot says
# deltas commute, so every one has to land whatever order it arrives in. # what was running when the event fired, and even a slightly stale one is a
# Dropping a "+1" because its timestamp lost a race leaves the count one # better answer than a number left over from an older event still. Nothing
# short, and the batch then frees the session while a subagent is still # accumulates, so nothing leaks when a subagent dies without ever sending
# running -- the exact failure counting was added to prevent. The timestamps # its SubagentStop -- the next event carrying a list corrects the count.
# cannot be trusted for this at all: each is taken when its hook process snapshot = running_agents(event)
# starts, tens of milliseconds before it reaches the lock. if snapshot is not None:
if name in ("SessionStart", "UserPromptSubmit"): agents = snapshot
# A new turn from you starts a new batch. This also bounds the damage
# when a subagent dies without its SubagentStop ever arriving: the count
# cannot leak past the next thing you type.
agents = 0
elif name == "PreToolUse":
agents += 1
elif name == "SubagentStop":
agents = max(0, agents - 1)
if stale: if stale:
# Keep the stored state and flag; the delta above still gets persisted. # Keep the stored state and flag; the snapshot above still stands.
state = previous.get("state", state) state = previous.get("state", state)
else: else:
if name in ("SessionStart", "UserPromptSubmit"): if name in ("SessionStart", "UserPromptSubmit"):
@@ -416,13 +468,11 @@ def apply_event(event, state, path, now):
state = "blocked" state = "blocked"
message = previous.get("message", "") message = previous.get("message", "")
# Resolved before the unchanged-check, not after, so that a pid which has # The pid is resolved by the caller and compared in the unchanged-check
# changed forces a write. A session resumed under a new pid, or one whose # 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 # 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 # long as its state happens not to change -- and the reader, finding that
# process gone, would drop a perfectly live session from the panel. # process gone, would drop a perfectly live session from the panel.
claude_pid = find_claude_pid()
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
@@ -485,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)
@@ -498,7 +555,7 @@ def main():
return 0 return 0
with lock: 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
+11 -9
View File
@@ -23,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, ""),
@@ -41,6 +35,14 @@ 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
@@ -90,9 +92,9 @@ def main():
shutil.copymode(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
+27 -11
View File
@@ -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,7 +9,17 @@
// //
// 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) {
@@ -22,29 +32,35 @@ function segments(name) {
.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();
@@ -61,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);
+45 -20
View File
@@ -61,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();
@@ -73,7 +74,14 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
// other way -- another extension rebuilding the panel boxes -- would // other way -- another extension rebuilding the panel boxes -- would
// leave the timer and the file monitor running against a disposed // leave the timer and the file monitor running against a disposed
// actor, screaming into the log every 20 seconds. // actor, screaming into the log every 20 seconds.
this.connect('destroy', () => this._onDestroy()); //
// 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();
} }
@@ -81,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,
@@ -176,6 +184,17 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
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 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 => ({
@@ -186,7 +205,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
since: s.started || s.since, 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)
@@ -204,21 +223,24 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
const shown = sessions.slice(0, maxChips); const shown = sessions.slice(0, maxChips);
const hidden = sessions.length - shown.length; const hidden = sessions.length - shown.length;
// Age rides on the first chip only. Sessions are sorted by urgency, so // Age rides on the working chips, and on all of them. A session that
// that is the one whose age decides anything; five ages side by side // is working is the one you might be waiting on, and how long it has
// would just be a wide row of numbers. // been at it is the whole question -- five minutes is a build, forty
const ageOnFirst = showAge && shown.length > 0; // 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 const signature = shown
.map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`) .map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`)
.join('|') + `|${ageOnFirst}|${hidden}`; .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();
shown.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, sessionId: session.sessionId }; this._ageLabels.set(session.sessionId, age);
this._chipBox.add_child(chip); this._chipBox.add_child(chip);
}); });
if (hidden > 0) { if (hidden > 0) {
@@ -231,13 +253,16 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
this._chipSignature = signature; this._chipSignature = signature;
} }
if (this._ageLabel) { // Looked up again rather than captured: a session that returns to the
// Looked up again rather than captured: a session that returns to // same state within one refresh keeps the signature unchanged, and a
// the same state within one refresh keeps the signature unchanged, // captured object would then show an age that stopped moving.
// and a captured object would then show an age that stopped moving. if (this._ageLabels.size) {
const current = sessions.find(s => s.sessionId === this._ageLabel.sessionId); const byId = new Map(sessions.map(s => [s.sessionId, s]));
for (const [sessionId, label] of this._ageLabels) {
const current = byId.get(sessionId);
if (current) if (current)
this._ageLabel.age.text = formatAge(this._ageOf(current)); label.text = formatAge(this._ageOf(current));
}
} }
} }
@@ -398,7 +423,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
// ---- Teardown ------------------------------------------------------- // ---- Teardown -------------------------------------------------------
_onDestroy() { _teardown() {
if (this._destroyed) if (this._destroyed)
return; return;
this._destroyed = true; this._destroyed = true;
+53 -4
View File
@@ -15,22 +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', dispGroup.add(this._spinRow(settings, 'max-chips',
_('Chips shown'), _('Chips shown'),
_('The most urgent sessions get a chip; the rest are counted as “+N”.'), _('The most urgent sessions get a chip; the rest are counted as “+N”.'),
1, 12)); 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'),
@@ -118,6 +141,32 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
return row; 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,9 +25,15 @@
</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"> <key name="max-chips" type="i">
<default>3</default> <default>3</default>
<range min="1" max="12"/> <range min="1" max="12"/>
@@ -20,8 +42,8 @@
</key> </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>
+26
View File
@@ -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);
+74 -27
View File
@@ -41,22 +41,51 @@ import importlib.util, sys
spec = importlib.util.spec_from_file_location("h", sys.argv[1]) spec = importlib.util.spec_from_file_location("h", sys.argv[1])
h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h) h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h)
cases = [ cases = [
("/bin/sh -c /home/u/claude-code-gnome-extension/hooks/claude-status-hook.py ", False), (["/bin/sh", "-c", "/home/u/claude-code-gnome-extension/hooks/claude-status-hook.py"], False),
("/home/u/.local/bin/claude --resume ", True), (["/home/u/.local/bin/claude", "--resume"], True),
("bash /home/u/bin/claude ", True), (["bash", "/home/u/bin/claude"], True),
("node /usr/lib/node_modules/@anthropic-ai/claude-code/cli.js ", True), (["node", "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"], True),
("/home/u/bin/zellij --server /run/user/1000/zellij/x ", False), (["/home/u/bin/zellij", "--server", "/run/user/1000/zellij/x"], False),
("nvim /home/u/.claude/settings.json ", False), (["nvim", "/home/u/.claude/settings.json"], False),
] ]
bad = 0 bad = 0
for cmd, want in cases: for argv, want in cases:
got = h.looks_like_claude(cmd) got = h.looks_like_claude(argv)
print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (cmd[:46], got)) 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 bad += got != want
sys.exit(1 if bad else 0) sys.exit(1 if bad else 0)
PY PY
check "command lines classified correctly" "0" "$?" 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)"
@@ -120,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 -----------------------------------------------------------
+34 -6
View File
@@ -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 === 'AdwSpinRow' || 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);
} }
@@ -70,9 +74,33 @@ for (const row of rows)
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 controls = rows.filter( const controls = rows.filter(
r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow').length; r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow' ||
r.type === 'AdwComboRow').length;
check('a control for every settings key', controls === keys, `${controls} of ${keys}`); 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.
const status = rows.find(r => r.title === 'Status'); const status = rows.find(r => r.title === 'Status');