Compare commits

...
4 Commits
Author SHA1 Message Date
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
15 changed files with 609 additions and 185 deletions
+64 -18
View File
@@ -42,6 +42,10 @@ Code ждёт меня прямо сейчас?**
текстом `«Claude needs your permission»`. Различить их можно было бы только через
`PreToolUse` — ценой записи файла на каждый вызов инструмента.
Чипов показывается три (настраивается), остальные сворачиваются в `+N`. Панель
живёт в центральном боксе рядом с часами, и без предела достаточно открытых
сессий сдвинули бы часы с центра.
Чипы идут по срочности, а внутри одного состояния первой стоит **самая
давняя**: забывается та, что ждёт дольше всех, а не последняя. Время в
состоянии показывается только у первого чипа: пять счётчиков рядом — это
@@ -178,31 +182,62 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
## zellij
Если сессии живут в табах zellij, меню показывает **имя таба**, а клик
переключает на него. Поиск идёт через `zellij action dump-layout` — рабочий
каталог сессии сопоставляется с каталогами пейнов, потому что в дампе раскладки
нет id пейнов и `ZELLIJ_PANE_ID` для этого не годится. Отсюда следствия: две
сессии в одном табе неразличимы, а сессия, сменившая `cwd` после открытия пейна,
не найдётся. Строки, которые не разрешились, остаются некликабельными — вместо
того чтобы делать вид, будто клик что-то делает.
Если сессии живут в табах zellij, меню называет **имя таба** — это лучший ответ
на «в какой терминал идти», чем путь. Поиск идёт через `zellij action
dump-layout`: рабочий каталог сессии сопоставляется с каталогами пейнов, потому
что в дампе раскладки нет id пейнов и `ZELLIJ_PANE_ID` для этого не годится.
Отсюда следствия: две сессии в одном табе неразличимы, а сессия, сменившая
`cwd` после открытия пейна, не найдётся.
Меню **ничего не делает** — только показывает. Ни строки, ни чипы не кликабельны:
переключение таба и подъём окна были написаны и убраны, потому что стоили заметной
логики (окна gnome-terminal нельзя сопоставить по pid — все они под одним
серверным процессом) ради экономии одного alt-tab.
Если zellij не используется, выключите в настройках: он стоит одного процесса
раз в пару минут.
## Аварийное завершение
`kill`, закрытое окно, перезагрузка — `SessionEnd` не приходит, и файл остаётся.
Разбор завалов проверен на каждом случае отдельно:
| Что осталось | Что с этим происходит |
|---|---|
| файл убитой сессии | процесса нет — файл и его `.lock` удаляются в пределах 20 с |
| файл из прошлой загрузки | pid сверяется по времени старта процесса, а не только по наличию |
| оборванная запись хука (`.tmp`) | удаляется, когда старше пяти минут |
| незакрытый `flock` | ядро снимает блокировку при смерти процесса — тупика не бывает |
Сверка по времени старта — не перестраховка. Файлы состояния переживают
перезагрузку, а pid после неё раздаются заново: проверка «есть ли `/proc/<pid>`»
отвечает лишь «какой-то процесс с таким номером есть». Без этой сверки сессия,
погибшая в аварии, висела бы в панели вечно, требуя ответа, которого некому дать.
Проверено подстановкой постороннего живого процесса на тот же pid.
Порог в пять минут для `.tmp` тоже осмысленный: хук может писать такой файл
прямо сейчас, и удаление свежего стоило бы потерянной записи.
Опознание самого процесса claude — отдельная тонкость. Хук запускается как
`/bin/sh -c /…/claude-status-hook.py`, поэтому в командной строке его родителя
слово «claude» **есть**, а сам он — не claude и живёт миллисекунды. Поэтому
совпадение ищется по отдельным аргументам (`argv[0]` называется `claude`, либо
`cli.js` внутри пути с `claude`), а не по строке целиком. Если опознать не
удалось, пишется pid 0.
Найденный pid сверяется на **каждом** событии, а не только при записи: сессия,
поднятая через `--resume`, получает новый pid, и без этой сверки файл сохранял бы
старый до ближайшей смены состояния — а читатель, не найдя того процесса, убрал бы
живую сессию из панели.
Если хук вообще не смог опознать процесс claude, он пишет pid 0 — «неизвестно»,
что никогда не путается с «мёртв»; такие записи истекают по возрасту, через
36 часов.
## Известные шероховатости
- **Протухшие сессии.** Убитый терминал не присылает `SessionEnd`. Живость
перепроверяется каждые 20 с по `/proc/<pid>`, файл удаляется — убитая сессия
исчезает в пределах этого окна, а не висит вечно. Если хук вообще не смог
опознать процесс claude, он записывает pid 0 — «неизвестно», что никогда не
путается с «мёртв», — и такие записи истекают по возрасту, через 36 часов.
- **`Stop` не отличает «закончил» от «сдался».** Оба читаются как «ждёт ввода»,
и решение для вас в обоих случаях одно и то же.
- **Фокус ведёт к табу zellij, а не к окну.** gnome-terminal держит все окна под
одним общим серверным процессом, так что окно нельзя сопоставить по pid. Окно
поднимается, только если в его заголовке есть имя zellij-сессии; когда
совпадения нет, таб всё равно переключается, а фокус остаётся на месте —
поднять произвольный терминал хуже, чем не поднимать никакой.
## Тесты
@@ -225,5 +260,16 @@ tests/test-hook.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, собранный вручную из чекаута, оставит расширение
без схемы настроек.
+208 -50
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.
"""
import errno
import fcntl
import json
import os
@@ -52,6 +53,36 @@ NOTIFICATION_STATES = {
# inflate the count with TaskCreate, TaskUpdate and the like.
AGENT_TOOLS = {"Agent", "Task"}
# 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 = {
# A session that has just opened is waiting for your first prompt, which is
# the same thing as one that has finished a turn: the input line is free
@@ -74,11 +105,23 @@ def debug_log(event):
environment, which cannot be changed without restarting the session.
"""
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
try:
with open(marker, "a") as fh:
stamped = dict(event, _at=time.strftime("%H:%M:%S"))
fd = os.open(marker, os.O_WRONLY | os.O_APPEND | os.O_NOFOLLOW)
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")
except OSError:
pass
@@ -145,32 +188,78 @@ def parent_of(pid):
return 0
def looks_like_claude(cmdline):
"""Is this command line the claude binary itself?
Matched per argument, never against the raw string. The hook is spawned as
`/bin/sh -c /.../claude-status-hook.py`, so its parent's command line
contains the word "claude" -- in a path -- without being claude at all.
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 cmdline.split(" "):
if not token:
continue
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 find_claude_pid():
"""Nearest ancestor that is the claude process itself, or 0 if unknown.
The hook is spawned through a shell, so the immediate parent is usually not
claude. Walking beyond a handful of levels risks latching onto an outer
claude when one session drives another, so the search stops early.
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
whose pid is gone, and the obvious fallback -- the shell that spawned this
hook -- exits milliseconds later, which would make the session flicker in
and out of the panel forever.
whose process is gone, and the obvious fallback -- the shell that spawned
this hook -- exits immediately.
"""
pid = os.getppid()
for _ in range(6):
if pid <= 1:
break
# Matched anywhere in the command line, not just argv[0]: installs that
# run it as `node .../claude/cli.js` are just as valid as a direct one.
if "claude" in read_cmdline(pid):
if looks_like_claude(read_cmdline(pid)):
return pid
pid = parent_of(pid)
return 0
def alive(pid):
return pid > 0 and os.path.exists("/proc/%d" % pid)
def pid_start_time(pid):
"""Field 22 of /proc/<pid>/stat: when the process started, in clock ticks.
Pins a pid to one particular process. Pids are reused, and state files
outlive reboots -- without this, a file left by a crashed session whose pid
is later handed to something unrelated reads as a live session forever.
"""
try:
with open("/proc/%d/stat" % pid) as fh:
data = fh.read()
except OSError:
return 0
# Field 2 is the command name, parenthesised, and may itself contain spaces
# and a ')'. Everything after the last ')' is field 3 onwards.
tail = data[data.rfind(")") + 2:].split()
try:
return int(tail[19])
except (IndexError, ValueError):
return 0
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):
@@ -190,12 +279,21 @@ def sweep_dead(keep):
path = os.path.join(STATE_DIR, name)
try:
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):
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
# 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"):
try:
os.unlink(victim)
@@ -210,6 +308,22 @@ def write_atomic(path, payload):
os.replace(tmp, path)
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):
"""Read the current state, decide, and write. Must run under the lock."""
if event.get("hook_event_name") == "SessionStart":
@@ -219,11 +333,13 @@ def apply_event(event, state, path, now):
sweep_dead(keep=os.path.basename(path))
if state == "end":
for victim in (path, path + ".lock"):
try:
os.unlink(victim)
except OSError:
pass
# Only the state file. Unlinking the lock while holding it drops mutual
# exclusion -- a hook already blocked on the old inode and one that
# creates a new file are then both inside the critical section.
try:
os.unlink(path)
except OSError:
pass
return
previous = None
@@ -238,76 +354,115 @@ def apply_event(event, state, path, now):
# Normalised here so the comparison below is against what would actually be
# stored: comparing a stored "" to a raw notification message rewrites the
# 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")
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
# state because with background subagents both are true at once: the turn is
# over and work is still running.
stopped = bool(previous.get("stopped")) if previous else False
# An event that lost a race carries an older timestamp than what is already
# stored. Its *state* must not be applied -- that is last-writer-wins, and
# replaying an old one would resurrect a state the session has left.
previous_ts = number(previous.get("event_ts")) if previous else 0
if previous_ts > now + FUTURE_SLACK:
previous_ts = 0 # corrupt, and trusting it would freeze this session
stale = previous_ts > now
# The subagent count is different in kind: the mutations are deltas, and
# deltas commute, so every one has to land whatever order it arrives in.
# Dropping a "+1" because its timestamp lost a race leaves the count one
# short, and the batch then frees the session while a subagent is still
# running -- the exact failure counting was added to prevent. The timestamps
# cannot be trusted for this at all: each is taken when its hook process
# starts, tens of milliseconds before it reaches the lock.
if name in ("SessionStart", "UserPromptSubmit"):
# A new turn from you starts a new batch. This also bounds the damage
# when a subagent dies without its SubagentStop ever arriving: the count
# cannot leak past the next thing you type.
agents, stopped = 0, False
agents = 0
elif name == "PreToolUse":
agents += 1
elif name == "SubagentStop":
agents = max(0, agents - 1)
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"
if stale:
# Keep the stored state and flag; the delta above still gets persisted.
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", "")
# Resolved before the unchanged-check, not after, so that a pid which has
# changed forces a write. 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.
claude_pid = find_claude_pid()
if previous:
# 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 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":
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.
if (previous.get("state") == state
and previous.get("message", "") == message
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
claude_pid = find_claude_pid()
env = read_environ(claude_pid) if claude_pid else {}
write_atomic(path, {
"session_id": event.get("session_id"),
"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
# last event, so "waiting 40 min" survives unrelated later writes.
"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".
"pid": claude_pid,
"pid_start": pid_start_time(claude_pid) if claude_pid else 0,
"event": event.get("hook_event_name", ""),
"notification_type": event.get("notification_type", ""),
"message": message,
"message": clip(message),
"agents": agents,
"stopped": stopped,
"zellij_session": env.get("ZELLIJ_SESSION_NAME", ""),
"zellij_pane": env.get("ZELLIJ_PANE_ID", ""),
"transcript": event.get("transcript_path", ""),
# Kept from the previous write when this event could not identify the
# process: a momentary failure should not blank out where the session is.
"zellij_session": clip(env.get("ZELLIJ_SESSION_NAME")
or (previous.get("zellij_session", "") if previous else "")),
})
@@ -338,7 +493,10 @@ def main():
# 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
# 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)
apply_event(event, state, path, now)
return 0
+28 -5
View File
@@ -11,7 +11,9 @@ Usage: install.py [--uninstall] [--settings PATH]
import json
import os
import shlex
import shutil
import stat
import sys
HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-hook.py")
@@ -42,7 +44,9 @@ EVENTS = {
def entry(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_:
hook["async"] = True
return {"matcher": matcher, "hooks": [hook]}
@@ -50,7 +54,7 @@ def entry(spec):
def is_ours(group):
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", [])
if isinstance(h, dict)
)
@@ -60,7 +64,15 @@ def main():
uninstall = "--uninstall" in sys.argv
path = os.path.expanduser("~/.claude/settings.json")
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:
with open(path) as fh:
@@ -70,8 +82,12 @@ def main():
except ValueError as 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.copymode(path, path + ".bak")
hooks = settings.setdefault("hooks", {})
for event in EVENTS:
@@ -92,10 +108,17 @@ def main():
with open(tmp, "w") as fh:
json.dump(settings, fh, indent=2)
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)
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__":
+5 -2
View File
@@ -13,9 +13,12 @@ const MAX = 3;
/** Split on separators and camelCase humps: "pet-project-server", "outlineMcp". */
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
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[^a-zA-Z0-9]+/)
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, '$1 $2')
.split(/[^\p{L}\p{N}]+/u)
.filter(Boolean);
}
+5 -1
View File
@@ -12,7 +12,11 @@ export function formatAge(seconds) {
if (m < 60)
return `${m}m`;
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
+4 -4
View File
@@ -1,8 +1,8 @@
// State glyphs, drawn with cairo alone.
//
// The panel is monochrome, so shape is the only channel left and these four
// have to stay apart at 14 px. Kept free of St/Clutter/GNOME imports so the
// shapes can be rendered to a file and looked at, rather than guessed about.
// The panel is monochrome, so shape is the only channel left and the three
// states have to stay apart at 14 px. Kept free of St/Clutter/GNOME imports so
// 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. */
export function drawState(cr, state, width, height, color) {
@@ -21,7 +21,7 @@ export function drawState(cr, state, width, height, color) {
switch (state) {
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
// big enough to register at 14 px, or this reads as plain "working".
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
+69 -64
View File
@@ -6,7 +6,6 @@ import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
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 PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.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.
*
* Nothing here picks a colour: the foreground comes from the theme node, so
@@ -44,8 +38,13 @@ function drawStateDot(area, state) {
try {
const [w, h] = area.get_surface_size();
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, {
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 {
cr.$dispose();
@@ -69,6 +68,12 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
this._changedId = this._store.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.
this.connect('destroy', () => this._onDestroy());
this._store.start();
}
@@ -101,18 +106,24 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
style_class: 'ccs-dot',
y_align: Clutter.ActorAlign.CENTER,
});
dot.set_width(14);
dot.set_height(14);
// Size comes from the stylesheet so St scales it: setting it here would
// pin the glyph to physical pixels and halve it on a HiDPI display.
dot.connect('repaint', area => drawStateDot(area, session.state));
chip.add_child(dot);
let age = null;
if (this._settings.get_boolean('show-project-name')) {
chip.add_child(new St.Label({
const text = new St.Label({
style_class: 'ccs-chip-label',
y_align: Clutter.ActorAlign.CENTER,
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) {
age = new St.Label({
@@ -164,11 +175,15 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
_updatePanel(sessions) {
const showAge = this._settings.get_boolean('show-age');
const abbreviate = this._settings.get_boolean('abbreviate-names');
const maxChips = this._settings.get_int('max-chips');
this._chipLabels = assignChips(
sessions.map(s => ({
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),
})),
this._chipLabels);
@@ -181,28 +196,49 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
// than sitting there empty.
this.visible = sessions.length > 0;
// Chips are ordered by urgency, so cutting the tail keeps the ones that
// need you soonest. Without a cap the row grows without bound, and it
// sits in the centre box -- enough sessions would shove the clock off
// centre. Labels are still assigned over every session, so the menu and
// 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 first chip only. Sessions are sorted by urgency, so
// that is the one whose age decides anything; five ages side by side
// would just be a wide row of numbers.
const ageOnFirst = showAge && sessions.length > 0;
const signature = sessions
const ageOnFirst = showAge && shown.length > 0;
const signature = shown
.map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`)
.join('|') + `|${ageOnFirst}`;
.join('|') + `|${ageOnFirst}|${hidden}`;
if (signature !== this._chipSignature) {
this._chipBox.destroy_all_children();
this._ageLabel = null;
sessions.forEach((session, i) => {
shown.forEach((session, i) => {
const { chip, age } = this._buildChip(
session, labelFor(session), ageOnFirst && i === 0);
if (age)
this._ageLabel = { age, session };
this._ageLabel = { age, sessionId: session.sessionId };
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;
}
if (this._ageLabel)
this._ageLabel.age.text = formatAge(this._ageOf(this._ageLabel.session));
if (this._ageLabel) {
// Looked up again rather than captured: a session that returns to
// the same state within one refresh keeps the signature unchanged,
// and a captured object would then show an age that stopped moving.
const current = sessions.find(s => s.sessionId === this._ageLabel.sessionId);
if (current)
this._ageLabel.age.text = formatAge(this._ageOf(current));
}
}
_updateMenu(sessions) {
@@ -210,15 +246,18 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
// Rebuild only when the set of sessions or their states changed; ages
// 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
.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('|');
if (signature !== this._rowSignature) {
this._rebuildRows(sessions);
this._rowSignature = signature;
}
const byId = new Map(sessions.map(s => [s.sessionId, s]));
for (const row of this._rows) {
const session = sessions.find(s => s.sessionId === row.sessionId);
const session = byId.get(row.sessionId);
if (!session)
continue;
row.age.text = formatAge(this._ageOf(session));
@@ -259,13 +298,12 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
}
_buildRow(session) {
const tab = this._tabFor(session);
// Reactivity is decided at construction, not patched afterwards:
// PopupBaseMenuItem latches _activatable in its constructor, so a row
// switched to reactive=false later keeps the styling of a clickable one
// and still looks like it does something.
// The menu reports; it does not act. Rows are built inert rather than
// switched off afterwards, because PopupBaseMenuItem latches
// _activatable in its constructor and a row demoted later keeps the
// styling of a clickable one.
const item = new PopupMenu.PopupBaseMenuItem(
tab ? {} : { reactive: false, can_focus: false });
{ reactive: false, can_focus: false });
item.add_style_class_name('ccs-row');
const column = new St.BoxLayout({ vertical: true, x_expand: true });
@@ -277,6 +315,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
style_class: `ccs-row-title ccs-${session.state}`,
x_expand: true,
});
title.clutter_text.ellipsize = Pango.EllipsizeMode.END;
const age = new St.Label({
text: formatAge(this._ageOf(session)),
style_class: 'ccs-row-age',
@@ -296,9 +335,6 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
column.add_child(subtitle);
item.add_child(column);
if (tab)
item.connect('activate', () => this._switchTo(session, tab));
this._rows.push({ sessionId: session.sessionId, age, subtitle });
return item;
}
@@ -360,41 +396,11 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
.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 -------------------------------------------------------
destroy() {
_onDestroy() {
if (this._destroyed)
return;
this._destroyed = true;
this._zellij.destroy();
if (this._changedId) {
@@ -406,6 +412,5 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
this._settingsChangedId = 0;
}
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.
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) {
const i = STATES.indexOf(state);
return i < 0 ? STATES.length : i;
@@ -128,16 +145,20 @@ export const SessionStore = GObject.registerClass({
let enumerator;
try {
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);
} catch (e) {
// 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 [];
throw e;
}
const names = [];
const locks = [];
for (;;) {
const batch = await enumerator.next_files_async(
32, GLib.PRIORITY_DEFAULT, cancellable);
@@ -145,15 +166,31 @@ export const SessionStore = GObject.registerClass({
break;
for (const info of batch) {
const name = info.get_name();
// ".tmp" files are half-written state; "debug" is the hook's
// opt-in event log and is not a session.
if (name.endsWith('.json'))
// Only ".json" is state. ".lock" belongs to the hook, "debug"
// is its opt-in event log, and ".tmp" is an interrupted write.
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);
} 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 = [];
for (const name of names) {
for (const name of names.slice(0, MAX_SESSIONS)) {
const session = await this._readOne(name, cancellable);
if (session)
sessions.push(session);
@@ -161,6 +198,24 @@ export const SessionStore = GObject.registerClass({
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) {
const file = this._dir.get_child(name);
let raw;
@@ -176,12 +231,13 @@ export const SessionStore = GObject.registerClass({
return null;
const pid = Number(raw.pid) || 0;
const pidStart = Number(raw.pid_start) || 0;
const eventTs = Number(raw.event_ts) || 0;
const age = GLib.get_real_time() / 1e6 - eventTs;
// 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
// 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) {
// The terminal was killed without a SessionEnd hook. Removing the
// file here (rather than only hiding it) keeps the directory from
@@ -209,12 +265,10 @@ export const SessionStore = GObject.registerClass({
state,
cwd: String(raw.cwd ?? ''),
since: Number(raw.since) || 0,
started: Number(raw.started) || 0,
pid,
message: String(raw.message ?? ''),
agents: Math.max(0, Number(raw.agents) || 0),
notificationType: String(raw.notification_type ?? ''),
zellijSession: String(raw.zellij_session ?? ''),
zellijPane: String(raw.zellij_pane ?? ''),
};
}
@@ -234,6 +288,33 @@ export const SessionStore = GObject.registerClass({
}
});
function isAlive(pid) {
return pid > 0 && GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS);
/** Is this pid still the process the hook recorded?
*
* 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.
//
// 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
// answer than a path, and zellij can be told to switch to it.
// where to look. When sessions live in zellij tabs, the tab name answers that
// better than a path does.
//
// `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
@@ -20,12 +20,18 @@ Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async');
// does not spawn a zellij process every time it runs.
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 {
constructor() {
this._cache = new Map(); // zellij session -> { at, tabs: [{name, cwds}] }
this._inFlight = new Map();
this._available = null;
this._cancellable = new Gio.Cancellable();
this._children = new Set();
}
/** Tab name for a working directory, or null when unknown. */
@@ -58,7 +64,7 @@ export class ZellijTabs {
return;
const now = GLib.get_monotonic_time() / 1e6;
const work = [];
for (const name of new Set(zellijSessions)) {
for (const name of [...new Set(zellijSessions)].slice(0, MAX_SESSIONS)) {
if (!name)
continue;
const entry = this._cache.get(name);
@@ -97,10 +103,15 @@ export class ZellijTabs {
const proc = Gio.Subprocess.new(
['zellij', '--session', session, 'action', 'dump-layout'],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE);
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
if (!proc.get_successful())
return null;
return stdout ?? '';
this._children.add(proc);
try {
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
if (!proc.get_successful())
return null;
return stdout ?? '';
} finally {
this._children.delete(proc);
}
} catch (e) {
// zellij not installed, or not on the shell's PATH: stop trying.
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. */
destroy() {
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._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.
@@ -146,11 +151,23 @@ export function parseLayout(text) {
tabs.push(current);
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)
continue;
const paneMatch = line.match(/^\s*pane\s.*?\bcwd="([^"]*)"/);
if (paneMatch) {
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('/')
? cwd
: GLib.build_filenamev([base, cwd]);
+22 -2
View File
@@ -24,6 +24,10 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
dispGroup.add(this._switchRow(settings, 'abbreviate-names',
_('Shorten names to three characters'),
_('“dev-skills” becomes “ds”. Collisions get a digit by seniority, so a label already on screen never changes.')));
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',
_('Show time in state'),
_('Shown on the most urgent session only.')));
@@ -36,7 +40,7 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
zellijGroup.add(this._switchRow(settings, 'zellij-integration',
_('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 ---------------------------------------------------------
// The indicator is only as good as the hooks feeding it, and a silent
@@ -81,6 +85,11 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
_hooksStatus() {
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 {
const [ok, bytes] = GLib.file_get_contents(path);
if (!ok)
@@ -91,13 +100,24 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
(g.hooks ?? []).some(h => (h.command ?? '').includes('claude-status-hook.py'))))
.map(([event]) => event);
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(', ')}`;
} catch (e) {
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;
}
_switchRow(settings, key, title, subtitle) {
const row = new Adw.SwitchRow({ title, subtitle });
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
@@ -12,6 +12,12 @@
<summary>Shorten project names to three characters</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>
</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">
<default>true</default>
<summary>Show how long the session has been in this state</summary>
@@ -20,7 +26,7 @@
<key name="zellij-integration" type="b">
<default>true</default>
<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>
</schema>
</schemalist>
+6
View File
@@ -34,6 +34,12 @@
font-feature-settings: "tnum";
}
.ccs-overflow {
font-size: 0.9em;
opacity: 0.7;
font-feature-settings: "tnum";
}
.ccs-summary {
font-weight: bold;
}
+34 -1
View File
@@ -32,10 +32,40 @@ ev() { # event [extra json]
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 cmd, want in cases:
got = h.looks_like_claude(cmd)
print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (cmd[:46], got))
bad += got != want
sys.exit(1 if bad else 0)
PY
check "command lines classified correctly" "0" "$?"
# --- state machine ---------------------------------------------------------
emit "$(ev SessionStart '"source":"startup"')"
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"')"
check "UserPromptSubmit -> busy" "busy" "$(field state)"
@@ -164,7 +194,10 @@ check "hook proceeds once the lock is released" "waiting" "$(field state)"
# --- teardown --------------------------------------------------------------
emit "$(ev SessionEnd '"reason":"other"')"
[ -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"
if [ "$failures" -gt 0 ]; then
+6 -4
View File
@@ -48,7 +48,7 @@ const rows = [];
const walk = widget => {
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
const type = c.constructor.$gtype.name;
if (type === 'AdwSwitchRow' || type === 'AdwActionRow')
if (type === 'AdwSwitchRow' || type === 'AdwSpinRow' || type === 'AdwActionRow')
rows.push({ type, title: c.title, subtitle: c.subtitle });
walk(c);
}
@@ -65,11 +65,13 @@ function check(name, condition, detail = '') {
for (const row of rows)
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)
.list_keys().length;
const switches = rows.filter(r => r.type === 'AdwSwitchRow').length;
check('a switch for every settings key', switches === keys, `${switches} of ${keys}`);
const controls = rows.filter(
r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow').length;
check('a control for every settings key', controls === keys, `${controls} of ${keys}`);
// 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.
+23 -3
View File
@@ -27,11 +27,12 @@ function check(name, condition, detail = '') {
}
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 = {
session_id: id, state, cwd, since: now - agoSeconds,
event_ts: now, pid, event: 'test', notification_type: '',
message: '', zellij_session: 'ztest', zellij_pane: '1', transcript: '',
event_ts: now, pid, pid_start: pidStart, event: 'test',
notification_type: '', message: '', zellij_session: 'ztest',
zellij_pane: '1', transcript: '',
};
GLib.file_set_contents(
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
// survive an upgrade in a session that was already open.
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, 'half.json']), '{"broken');
@@ -67,6 +79,10 @@ store.connect('changed', () => {
stateRank('waiting') < stateRank('busy'));
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,
`got ${s.length}: ${s.map(x => x.sessionId).join(',')}`);
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);
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',
!GLib.file_test(GLib.build_filenamev([STATE, 's-dead.json']), GLib.FileTest.EXISTS));