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.
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.
Corrected from the previous commit, which had it backwards. When a batch
is running the session is working, not waiting: the main agent will pick
the results up and consolidate them itself, so sending you to that
terminal wastes the trip. That is the common shape of the work here --
ask for a batch, let it run.
Simply letting subagent tool calls set "busy" would mostly work and was
tempting, but it leaves a hole. Stop fires before the batch finishes, so
the session shows as free from the moment the turn ends until the first
subagent tool call lands -- and longer whenever the subagents are thinking
rather than calling tools. So subagents are counted instead:
PreToolUse, matched to ^(Agent|Task)$ +1
SubagentStop -1
UserPromptSubmit reset to 0
While the count is above zero the session cannot read as waiting; Stop and
an idle_prompt nudge both leave it working. The session is freed by the
last subagent leaving, and only if the main agent has stopped by then.
The matcher is anchored because it is a regex: a bare "Task" also matches
TaskCreate and friends, which are not subagents. The hook re-checks the
tool name itself in case a future matcher behaves differently, and the
reset on UserPromptSubmit bounds a count that leaks because a subagent
died without its SubagentStop.
Measured, not assumed: a matched PreToolUse fires only on agent launches,
SubagentStop arrives once per subagent carrying agent_id, and a real
three-subagent run walks the count 0-1-2-3-2-0 before Stop frees it.
The menu shows the number, as asked. The panel does not: a batch of eight
is still one line saying "working 40 min", which is the right line.
"Idle" was set by SessionStart and by nothing else, and there was no path
back into it. So it never meant "sitting unused" -- it meant "opened and
never asked anything yet", a state a few seconds long that you would
almost never catch. Meanwhile a session that finished an hour ago and was
forgotten showed as waiting, which is correct but leaves the fourth state
with nothing to describe.
A session that has just opened is waiting for your first prompt exactly
as one that finished a turn is waiting for your next. They are the same
thing, and now they are the same state. Three glyphs instead of four,
which also gives the remaining three more room to be told apart in a
monochrome panel.
Files written by the previous hook still say "idle", and a session open
across the upgrade must not disappear, so unrecognised states now read as
waiting rather than being treated as unknown. Covered by a test that
feeds an "idle" file to the store and asserts it comes back as waiting,
sorted by age among the others.
The "hide when nothing is running" setting goes with it. Its condition
was "no sessions, or all of them idle"; with idle gone the second half is
unreachable and the first was already unconditional, so the switch could
no longer change anything. A control that does nothing is worse than no
control.
The compaction test also got stronger in passing: it now checks that a
mid-turn SessionStart leaves a *busy* session alone, which is the case
that matters. It used to assert from waiting, where the state it was
guarding against happened to be the state already stored.
Two changes that turned out to be related.
The chips painted their own red, amber and green. An indicator that picks
its own colours competes with the shell's accents and stops following the
theme, so the panel now draws everything in the panel's text colour and
lets shape carry the state. Only alpha still varies, to push idle sessions
back.
That put real weight on the four shapes being distinguishable at 14 px,
which is not something to guess at, so the drawing moved to lib/glyph.js
-- free of St, Clutter and shell imports precisely so the glyphs can be
rendered to a file and looked at. They were, and the first attempt was
wrong twice: a disc filled to the ring's centreline reads smaller than the
ring beside it, and the inner disc of "blocked" was too small to tell from
a plain "working" ring. Radii are now matched at the outer edge and the
inner disc sits at 0.55, both settled by comparing renders.
Separately, hooking a live session showed the Notification that accompanies
a permission prompt carries only "Claude needs your permission" -- no tool,
no command -- and arrives identically when the session is putting a question
to the user rather than asking to run something. The menu was giving that
string the line, pushing out the tab and the path: strictly less information
in more space. It is gone, and the comment claiming it "says what is about
to run" is corrected. Recovering the real command means reading the tail of
the transcript when the prompt fires; it is not in the event.
The same finding settles how far the states can split. "Blocked" cannot be
divided into "asking permission" and "asking a question" from this data.
The panel showed a single aggregate: the most urgent session, plus a "+N"
for the others. That answers "what is the worst thing happening", which
is not the question with five projects open -- "what is each of them
doing" needs each of them on screen.
Each session now gets a chip: state glyph plus a three-character project
label, in the centre box just right of the clock. Time in state stays on
the first chip only; five counters side by side are a row of numbers,
not an answer.
Labels are initials for multi-segment names, first letters otherwise.
Initials rather than a prefix, because a prefix collapses dev-skills and
dev-conventions onto the same "dev" -- exactly the pair that has to stay
apart. Collisions, including two sessions in one project where the cwd
is identical, take a digit: ds, ds2, ds3.
Two rules keep a label still, and the feature is worthless without them.
Assignment runs oldest-session-first, so a session starting now takes
the suffix instead of displacing one already on screen; and a label
belongs to its session until it ends, even after whatever forced the
digit has closed. A label that moves under your hand is worse than one
carrying a digit that no longer looks necessary.
The zellij tab name was considered as the label source and dropped. It
arrives asynchronously from dump-layout, so a sticky label would freeze
whatever the project name produced first and never adopt it. Digits
already separate same-project sessions, so the tab name buys nothing
here and stays where it is useful, in the menu.
Menu rows now lead with the same label, so the mapping from "ds" to
dev-skills is read rather than guessed.
Answers one question at a glance: is any session waiting for me, and
which one. With several sessions open the cost is not knowing what each
is doing, it is noticing that one stopped an hour ago.
Claude Code hooks write one JSON file per session under
~/.local/state/claude-code-status; the extension watches the directory
with Gio.FileMonitor, so nothing polls and there is no daemon.
Two distinctions carry the design:
* blocked (permission prompt) is kept apart from waiting (turn done).
Merged, a finished task looks as urgent as a stuck one, which is
exactly the judgement the indicator exists to make.
* the panel names the oldest session in the top state, not the latest.
The session you forget is the one that has been waiting longest.
PostToolUse is registered although it looks redundant: it is the only
event that fires after a permission is granted, so without it a session
stays blocked in the panel for the rest of the turn. It writes only on
an actual state change, so the usual case costs no I/O.
Stop and SessionEnd are synchronous, unlike the rest. Both fire as the
process is about to go quiet, and an async hook racing that exit gets
killed before it writes -- claude -p left a session pinned at busy.
Concurrent hooks for one session serialise on an flock plus a timestamp
guard; tests/test-hook.sh covers each separately, because the burst test
passes on the timestamp guard alone.
Sessions running in zellij are located by tab name rather than by path,
matched through dump-layout on the working directory. The dump carries
no pane ids, so ZELLIJ_PANE_ID cannot be used; rows that do not resolve
stay inert instead of pretending a click does something.
lib/sessions.js deliberately imports nothing from the shell resource
namespace, which lets the riskiest logic -- liveness, ordering, partial
reads, monitoring -- run under plain gjs in tests/test-sessions.js.