Compare commits

..
8 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
av 2565d45bb5 Act on four reviews: state machine, resource bounds, teardown
Four agents reviewed this in parallel -- correctness, GNOME integration,
edge cases, security. Everything below was reproduced before being fixed;
several findings that survived the first reading did not survive a probe
and are not here.

State machine, the two that mattered most. A pending permission prompt was
erased by any subagent bookkeeping event: SubagentStop or the next
PreToolUse recomputed the state from scratch, so a session sat at "working"
with a dialog open and nothing ever raised it again. Blocked now outlives
everything except evidence the question was answered. Separately, the
stale-event guard refused whole events, including the subagent counter's
increments and decrements -- but those are deltas and deltas commute, so a
"+1" that lost a timestamp race left the count short and the batch freed
the session while a subagent was still running. The guard now gates the
state decision only.

Corrupt or hostile state files could wedge the panel or take the hook down
for every session: a non-numeric pid raised inside sweep_dead before the
hook wrote its own file, so one bad byte stopped new sessions appearing at
all. Numbers read back from disk are coerced, one unreadable file no longer
aborts the sweep, and a stored timestamp far in the future -- corruption, or
a clock stepped backwards by NTP -- no longer refuses every later event
forever.

Resource bounds, all in the compositor process. A state file was read whole
with no size check: a symlink to /dev/zero took a test process past 4 GB in
three seconds, which in gnome-shell ends the session. Sizes are checked
before the read, sessions and zellij subprocesses are capped, labels
ellipsize, and cwd and messages are truncated at the hook.

Teardown hung off an overridden destroy(), which only runs when JS calls
it. An actor destroyed any other way -- another extension rebuilding the
panel boxes -- left the timer and the file monitor running against a
disposed actor. It is a destroy signal now. The zellij child is killed
rather than merely abandoned.

The glyph was pinned to physical pixels and rendered half-size on HiDPI;
size comes from the stylesheet, and the foreground colour is normalised by
inspection rather than assuming which colour struct the shell hands back.

Chip labels: non-Latin names all collapsed to "?", because the split
treated every Cyrillic letter as a separator -- notable for a tool whose
own README is Russian. Seniority also ranked by time-in-state rather than
session age, so after a shell restart the older session could take the
digit; the hook now records when the session began.

zellij: a dump ends with new_tab_template and swap_tiled_layout blocks
whose tab lines carry no name, and their panes were being attached to the
last real tab -- which then answered for every unmatched directory,
confidently and wrongly.

install.py no longer widens the mode of a settings.json someone narrowed to
0600, no longer overwrites the pristine .bak on a second run, no longer
replaces a symlink out of a dotfiles repository with a regular file, and
quotes the hook path. The debug log is capped and README now says plainly
that it records prompts verbatim.

Not fixed, deliberately: the panel does push the clock about 70 px left
with three labelled chips, which is inherent to putting them in the centre
box; two different projects abbreviating alike still read as one project
with a digit; GNOME 48 remains unverified for the colour struct and for
St.BoxLayout's vertical property, both flagged rather than guessed at.
2026-08-09 20:20:45 +03:00
av f4a06cdc44 Identify the claude process by argument, not by substring
Testing the reboot case exposed a live regression, and the identity check
added for reboots is what made it visible.

Matching "claude" anywhere in an ancestor's command line was too loose. The
hook is spawned as `/bin/sh -c /.../claude-status-hook.py`, so its parent's
command line contains "claude" -- in the path to this very script -- while
being a shell that exits milliseconds later. That shell's pid was being
recorded as the session's. Existence checks alone hid it: the pid was dead,
the file was deleted, the next event recreated it, and the session flickered.
Once the pid was pinned to a process start time the session vanished
outright.

The rule was broadened in the first place to cover npm-style installs that
run `node .../claude-code/cli.js`, which was a real gap. It is now matched
per argument instead: argv[0] named claude, or a cli.js under a claude path.
Both installs pass, and the spawning shell does not.

The pid is also resolved before the unchanged-check rather than after, so a
pid that has changed forces a write. A session resumed under a new pid --
which is exactly what --resume does, and what this session had done -- kept
its old pid for as long as its state happened not to change, and the reader
would drop it as dead.

The debug log now records the process ancestry, which is what made this
diagnosable at all rather than guessable.
2026-08-09 19:47:19 +03:00
av 75bfe77950 Survive a crash, and a reboot after one
kill, a closed window, a reboot: no SessionEnd arrives and the state file
stays. Each case was tried rather than reasoned about, and one of the
three was broken.

A killed session was already handled -- the process is gone, so the file
and its lock are removed within the 20 s liveness tick. An interrupted
hook write left its temporary file behind forever; those are now swept
once they are five minutes old, which is late enough that a hook part-way
through writing one does not lose the update.

The reboot case was the broken one. State files outlive a reboot and pids
are handed out afresh, so "does /proc/<pid> exist" only answers "is some
process wearing that number". Verified by giving an unrelated live process
the pid of a dead session: the ghost sat in the panel as a session waiting
for input, and would have stayed there forever, asking for an answer
nobody could give. The pid is now pinned to the process start time from
/proc/<pid>/stat, recorded when the state is written and compared when it
is read.

Files written before that field existed compare only on existence, as
before, so a session open across the upgrade is not evicted.

An abandoned flock needed nothing: the kernel drops it when the holder
dies, so there is no deadlock to recover from.
2026-08-09 19:43:31 +03:00
av ca42d69704 Cap the chips at three, and make the menu report-only
Two changes that pull in the same direction: the panel says less, and the
menu stops pretending to do anything.

The chip row had no bound. It sits in the centre box next to the clock, so
enough open sessions would have shoved the clock off centre. It now shows
the first N, settable and three by default, and counts the rest as "+N".
Chips are already ordered by urgency, so the ones that survive the cut are
the ones that need you soonest. Labels are still assigned across every
session, including hidden ones, so a chip does not change when the cap
does or when a session ahead of it disappears.

Clicking a menu row used to switch the zellij tab and raise a terminal.
That is gone. It cost real machinery for what it saved -- gnome-terminal
runs every window under one shared server process, so windows cannot be
matched by pid and the code fell back to matching the zellij session name
against window titles, with all the ways that misses. The menu reports
status; alt-tab is not the bottleneck. Rows are built inert rather than
demoted after the fact, because PopupBaseMenuItem latches _activatable in
its constructor.

zellij tab lookup stays: naming the tab is the better half of that feature
and costs one process every couple of minutes.

The preferences test now asserts a control per settings key rather than a
switch per key, so the new spin row counts and a future non-boolean
setting cannot slip in without one.
2026-08-09 19:37:38 +03:00
18 changed files with 1147 additions and 301 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`.
+158 -43
View File
@@ -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
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();
} }
} }
+293 -78
View File
@@ -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 return 0, []
def alive(pid): def pid_start_time(pid):
return pid > 0 and os.path.exists("/proc/%d" % 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
def alive(pid, start=0):
"""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,11 +393,13 @@ 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
try: # exclusion -- a hook already blocked on the old inode and one that
os.unlink(victim) # creates a new file are then both inside the critical section.
except OSError: try:
pass os.unlink(path)
except OSError:
pass
return return
previous = None previous = None
@@ -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
if name in ("SessionStart", "UserPromptSubmit"): # An event that lost a race carries an older timestamp than what is already
# A new turn from you starts a new batch. This also bounds the damage # stored. Its *state* must not be applied -- that is last-writer-wins, and
# when a subagent dies without its SubagentStop ever arriving: the count # replaying an old one would resurrect a state the session has left.
# cannot leak past the next thing you type. previous_ts = number(previous.get("event_ts")) if previous else 0
agents, stopped = 0, False if previous_ts > now + FUTURE_SLACK:
elif name == "PreToolUse": previous_ts = 0 # corrupt, and trusting it would freeze this session
agents += 1 stale = previous_ts > now
elif name == "SubagentStop":
agents = max(0, agents - 1)
elif name == "Stop":
stopped = True
elif name == "PostToolUse":
stopped = False
if name == "SubagentStop": # Applied whether or not this event lost the race above: a snapshot says
# The last subagent finishing is what finally frees a session whose main # what was running when the event fired, and even a slightly stale one is a
# agent stopped long ago. # better answer than a number left over from an older event still. Nothing
state = "waiting" if (stopped and agents == 0) else "busy" # accumulates, so nothing leaks when a subagent dies without ever sending
elif state == "waiting" and agents > 0: # its SubagentStop -- the next event carrying a list corrects the count.
# The turn ended but the batch is still running, and the session will snapshot = running_agents(event)
# pick the results up itself. Calling it "waiting" would send you to a if snapshot is not None:
# terminal that does not need you. agents = snapshot
state = "busy"
if stale:
# Keep the stored state and flag; the snapshot above still stands.
state = previous.get("state", state)
else:
if name in ("SessionStart", "UserPromptSubmit"):
stopped = False
elif name == "Stop":
stopped = True
elif name == "PostToolUse":
stopped = False
if name == "SubagentStop":
# The last subagent finishing is what finally frees a session whose
# main agent stopped long ago.
state = "waiting" if (stopped and agents == 0) else "busy"
elif state == "waiting" and agents > 0:
# The turn ended but the batch is still running, and the session
# will pick the results up itself. Calling it "waiting" would send
# you to a terminal that does not need you.
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
+39 -14
View File
@@ -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:
path = sys.argv[sys.argv.index("--settings") + 1] try:
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
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,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);
+5 -1
View File
@@ -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);
return `${h}h ${m % 60}m`; if (h < 24)
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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
} }
+35 -18
View File
@@ -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);
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable); this._children.add(proc);
if (!proc.get_successful()) try {
return null; const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
return stdout ?? ''; if (!proc.get_successful())
return null;
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]);
+75 -6
View File
@@ -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>
+6
View File
@@ -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
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);
+99 -19
View File
@@ -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
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 === '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
View File
@@ -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));